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 tracing::info!("=== Starting book processing: {} ===", book_folder.name);
66
67 if !output_dir.exists() {
69 std::fs::create_dir_all(output_dir)
70 .context("Failed to create output directory")?;
71 }
72
73 let output_filename = book_folder.get_output_filename();
75 let output_path = output_dir.join(&output_filename);
76
77 let temp_dir = self.create_temp_dir(&book_folder.name)?;
79
80 let use_copy = book_folder.can_use_concat_copy();
82
83 tracing::info!(
84 "Processing {} - {} tracks, copy_mode={}",
85 book_folder.name,
86 book_folder.tracks.len(),
87 use_copy
88 );
89
90 let mut quality = book_folder
92 .get_best_quality_profile(true)
93 .context("No tracks found")?
94 .clone();
95
96 if let Some(ref preset) = self.quality_preset {
98 quality = quality.apply_preset(Some(preset.as_str()));
99 tracing::info!("Applying quality preset '{}': {}", preset, quality);
100 }
101
102 if book_folder.tracks.len() == 1 {
103 self.ffmpeg
105 .convert_single_file(
106 &book_folder.tracks[0].file_path,
107 &output_path,
108 &quality,
109 use_copy,
110 self.encoder,
111 )
112 .await
113 .context("Failed to convert audio file")?;
114 } else if use_copy {
115 let concat_file = temp_dir.join("concat.txt");
117 let file_refs: Vec<&Path> = book_folder
118 .tracks
119 .iter()
120 .map(|t| t.file_path.as_path())
121 .collect();
122 FFmpeg::create_concat_file(&file_refs, &concat_file)?;
123
124 self.ffmpeg
125 .concat_audio_files(
126 &concat_file,
127 &output_path,
128 &quality,
129 use_copy,
130 self.encoder,
131 )
132 .await
133 .context("Failed to concatenate audio files")?;
134 } else if self.enable_parallel_encoding && book_folder.tracks.len() > 1 {
135 let effective_limit = self.max_concurrent_files.min(book_folder.tracks.len());
137
138 tracing::info!(
139 "Using parallel encoding: {} files with max {} concurrent",
140 book_folder.tracks.len(),
141 effective_limit
142 );
143
144 let semaphore = Arc::new(Semaphore::new(effective_limit));
146
147 let mut encoded_files = Vec::new();
149 let mut tasks = Vec::new();
150
151 for (i, track) in book_folder.tracks.iter().enumerate() {
152 let temp_output = temp_dir.join(format!("encoded_{:04}.m4a", i));
153 encoded_files.push(temp_output.clone());
154
155 tracing::info!(
156 "[{}/{}] Encoding: {} ({:.1} min)",
157 i + 1,
158 book_folder.tracks.len(),
159 track.file_path.file_name().unwrap().to_string_lossy(),
160 track.quality.duration / 60.0
161 );
162
163 let ffmpeg = self.ffmpeg.clone();
164 let input = track.file_path.clone();
165 let output = temp_output;
166 let quality = quality.clone();
167 let encoder = self.encoder;
168 let sem = Arc::clone(&semaphore);
169
170 let task = tokio::spawn(async move {
172 let _permit = sem.acquire().await.unwrap();
174
175 ffmpeg
176 .convert_single_file(&input, &output, &quality, false, encoder)
177 .await
178 });
180
181 tasks.push(task);
182 }
183
184 for (i, task) in tasks.into_iter().enumerate() {
186 match task.await {
187 Ok(Ok(())) => continue,
188 Ok(Err(e)) => {
189 return Err(e).context(format!("Track {} encoding failed", i));
190 }
191 Err(e) => {
192 return Err(anyhow::anyhow!("Task {} panicked: {}", i, e));
193 }
194 }
195 }
196
197 tracing::info!("All {} files encoded, now concatenating...", encoded_files.len());
198
199 let concat_file = temp_dir.join("concat.txt");
201 let file_refs: Vec<&Path> = encoded_files.iter().map(|p| p.as_path()).collect();
202 FFmpeg::create_concat_file(&file_refs, &concat_file)?;
203
204 self.ffmpeg
205 .concat_audio_files(
206 &concat_file,
207 &output_path,
208 &quality,
209 true, self.encoder,
211 )
212 .await
213 .context("Failed to concatenate encoded files")?;
214 } else {
215 tracing::info!("Using serial encoding (parallel encoding disabled in config)");
217
218 let concat_file = temp_dir.join("concat.txt");
219 let file_refs: Vec<&Path> = book_folder
220 .tracks
221 .iter()
222 .map(|t| t.file_path.as_path())
223 .collect();
224 FFmpeg::create_concat_file(&file_refs, &concat_file)?;
225
226 self.ffmpeg
227 .concat_audio_files(
228 &concat_file,
229 &output_path,
230 &quality,
231 false, self.encoder,
233 )
234 .await
235 .context("Failed to concatenate audio files")?;
236 }
237
238 tracing::info!("Audio processing complete: {}", output_path.display());
239
240 let chapters = self.generate_chapters(book_folder, chapter_source)?;
242
243 if !chapters.is_empty() {
244 tracing::info!("Injecting {} chapters using MP4Box", chapters.len());
245
246 let chapters_file = temp_dir.join("chapters.txt");
247 write_mp4box_chapters(&chapters, &chapters_file)
248 .context("Failed to write chapter file")?;
249
250 inject_chapters_mp4box(&output_path, &chapters_file)
251 .await
252 .context("Failed to inject chapters")?;
253
254 tracing::info!("✓ Chapter injection complete");
255 }
256
257 let title = book_folder.get_album_title();
259 let artist = book_folder.get_album_artist();
260 let year = book_folder.get_year();
261 let genre = book_folder.get_genre();
262
263 tracing::info!("Injecting metadata using AtomicParsley");
264 tracing::debug!(
265 "Metadata: title={:?}, artist={:?}",
266 title,
267 artist
268 );
269
270 inject_metadata_atomicparsley(
271 &output_path,
272 title.as_deref(),
273 artist.as_deref(),
274 title.as_deref(), year,
276 genre.as_deref(),
277 book_folder.cover_file.as_deref(),
278 )
279 .await
280 .context("Failed to inject metadata")?;
281
282 tracing::info!("✓ Metadata injection complete");
283
284 if !self.keep_temp {
286 if let Err(e) = std::fs::remove_dir_all(&temp_dir) {
287 tracing::warn!("Failed to remove temp directory: {}", e);
288 }
289 }
290
291 let processing_time = start_time.elapsed().as_secs_f64();
293
294 tracing::info!(
295 "=== Completed: {} in {:.1}s ===",
296 book_folder.name,
297 processing_time
298 );
299
300 Ok(result.success(output_path, processing_time, use_copy))
302 }
303
304 fn generate_chapters(
306 &self,
307 book_folder: &BookFolder,
308 chapter_source: &str,
309 ) -> Result<Vec<crate::audio::Chapter>> {
310 match chapter_source {
311 "cue" => {
312 if let Some(ref cue_file) = book_folder.cue_file {
314 tracing::info!("Using CUE file for chapters: {}", cue_file.display());
315 return parse_cue_file(cue_file);
316 }
317 Ok(Vec::new())
318 }
319 "files" | "auto" => {
320 if book_folder.tracks.len() > 1 {
322 let files: Vec<&Path> = book_folder
323 .tracks
324 .iter()
325 .map(|t| t.file_path.as_path())
326 .collect();
327 let durations: Vec<f64> = book_folder
328 .tracks
329 .iter()
330 .map(|t| t.quality.duration)
331 .collect();
332
333 tracing::info!(
334 "Generating {} chapters from files",
335 book_folder.tracks.len()
336 );
337 Ok(generate_chapters_from_files(&files, &durations))
338 } else {
339 if let Some(ref cue_file) = book_folder.cue_file {
341 tracing::info!("Using CUE file for single-file book");
342 parse_cue_file(cue_file)
343 } else {
344 Ok(Vec::new())
345 }
346 }
347 }
348 "none" => Ok(Vec::new()),
349 _ => {
350 tracing::warn!("Unknown chapter source: {}, using auto", chapter_source);
351 self.generate_chapters(book_folder, "auto")
352 }
353 }
354 }
355
356 fn create_temp_dir(&self, book_name: &str) -> Result<PathBuf> {
358 let temp_base = std::env::temp_dir();
359 let sanitized_name = sanitize_filename::sanitize(book_name);
360 let temp_dir = temp_base.join(format!("audiobook-forge-{}", sanitized_name));
361
362 if temp_dir.exists() {
363 std::fs::remove_dir_all(&temp_dir).ok();
364 }
365
366 std::fs::create_dir_all(&temp_dir).context("Failed to create temp directory")?;
367
368 Ok(temp_dir)
369 }
370}
371
372impl Default for Processor {
373 fn default() -> Self {
374 Self::new().expect("Failed to create processor")
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 #[test]
383 fn test_processor_creation() {
384 let processor = Processor::new();
385 assert!(processor.is_ok());
386 }
387
388 #[test]
389 fn test_processor_with_options() {
390 let processor = Processor::with_options(true, AacEncoder::AppleSilicon, true, 8, None).unwrap();
391 assert!(processor.keep_temp);
392 assert_eq!(processor.encoder, AacEncoder::AppleSilicon);
393 assert_eq!(processor.max_concurrent_files, 8);
394 assert_eq!(processor.quality_preset, None);
395
396 let processor_with_preset = Processor::with_options(false, AacEncoder::Native, true, 4, Some("high".to_string())).unwrap();
397 assert_eq!(processor_with_preset.quality_preset, Some("high".to_string()));
398 }
399
400 #[test]
401 fn test_create_temp_dir() {
402 let processor = Processor::new().unwrap();
403 let temp_dir = processor.create_temp_dir("Test Book").unwrap();
404
405 assert!(temp_dir.exists());
406 assert!(temp_dir.to_string_lossy().contains("audiobook-forge"));
407
408 std::fs::remove_dir_all(temp_dir).ok();
410 }
411}