audiobook_forge/core/
processor.rs

1//! Single book processor
2
3use 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
14/// Processor for converting a single audiobook
15pub 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    /// Create a new processor
26    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    /// Create processor with options
38    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    /// Process a single book folder
56    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        // Create output directory if it doesn't exist
66        if !output_dir.exists() {
67            std::fs::create_dir_all(output_dir)
68                .context("Failed to create output directory")?;
69        }
70
71        // Determine output file path
72        let output_filename = book_folder.get_output_filename();
73        let output_path = output_dir.join(&output_filename);
74
75        // Create temp directory
76        let temp_dir = self.create_temp_dir(&book_folder.name)?;
77
78        // Check if we can use copy mode
79        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        // Get quality profile (auto-detected from source)
89        let mut quality = book_folder
90            .get_best_quality_profile(true)
91            .context("No tracks found")?
92            .clone();
93
94        // Apply quality preset override if specified
95        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            // Single file - just convert
102            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            // Copy mode - can concatenate directly
114            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            // Transcode mode - encode files in parallel with throttling
134            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            // Create semaphore to limit concurrent file encodings
143            let semaphore = Arc::new(Semaphore::new(effective_limit));
144
145            // Step 1: Encode all files to AAC/M4A in parallel (with throttling)
146            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                // Spawn parallel encoding task with semaphore
161                let task = tokio::spawn(async move {
162                    // Acquire permit before encoding (blocks if limit reached)
163                    let _permit = sem.acquire().await.unwrap();
164
165                    ffmpeg
166                        .convert_single_file(&input, &output, &quality, false, encoder)
167                        .await
168                    // Permit automatically released when _permit drops
169                });
170
171                tasks.push(task);
172            }
173
174            // Wait for all encoding tasks to complete
175            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            // Step 2: Concatenate the encoded files (fast, no re-encoding)
190            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, // use copy mode for concatenation
200                    self.encoder,
201                )
202                .await
203                .context("Failed to concatenate encoded files")?;
204        } else {
205            // Serial mode - concatenate and encode in one FFmpeg call (traditional method)
206            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, // transcode mode
222                    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        // Step 3: Generate and inject chapters
231        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        // Step 4: Inject metadata
246        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(), // Use title as album
256            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        // Clean up temp directory
266        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        // Calculate processing time
273        let processing_time = start_time.elapsed().as_secs_f64();
274
275        // Return success result
276        Ok(result.success(output_path, processing_time, use_copy))
277    }
278
279    /// Generate chapters for the book
280    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                // Use CUE file if available
288                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                // Generate chapters from files
296                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                    // Single file - check for CUE
315                    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    /// Create temporary directory for processing
332    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        // Clean up
384        std::fs::remove_dir_all(temp_dir).ok();
385    }
386}