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}
22
23impl Processor {
24    /// Create a new processor
25    pub fn new() -> Result<Self> {
26        Ok(Self {
27            ffmpeg: FFmpeg::new()?,
28            keep_temp: false,
29            encoder: crate::audio::get_encoder(),
30            enable_parallel_encoding: true,
31            max_concurrent_files: 8,
32        })
33    }
34
35    /// Create processor with options
36    pub fn with_options(
37        keep_temp: bool,
38        encoder: AacEncoder,
39        enable_parallel_encoding: bool,
40        max_concurrent_files: usize,
41    ) -> Result<Self> {
42        Ok(Self {
43            ffmpeg: FFmpeg::new()?,
44            keep_temp,
45            encoder,
46            enable_parallel_encoding,
47            max_concurrent_files: max_concurrent_files.clamp(1, 32),
48        })
49    }
50
51    /// Process a single book folder
52    pub async fn process_book(
53        &self,
54        book_folder: &BookFolder,
55        output_dir: &Path,
56        chapter_source: &str,
57    ) -> Result<ProcessingResult> {
58        let start_time = Instant::now();
59        let result = ProcessingResult::new(book_folder.name.clone());
60
61        // Create output directory if it doesn't exist
62        if !output_dir.exists() {
63            std::fs::create_dir_all(output_dir)
64                .context("Failed to create output directory")?;
65        }
66
67        // Determine output file path
68        let output_filename = book_folder.get_output_filename();
69        let output_path = output_dir.join(&output_filename);
70
71        // Create temp directory
72        let temp_dir = self.create_temp_dir(&book_folder.name)?;
73
74        // Check if we can use copy mode
75        let use_copy = book_folder.can_use_concat_copy();
76
77        tracing::info!(
78            "Processing {} - {} tracks, copy_mode={}",
79            book_folder.name,
80            book_folder.tracks.len(),
81            use_copy
82        );
83
84        // Get quality profile
85        let quality = book_folder
86            .get_best_quality_profile(true)
87            .context("No tracks found")?;
88
89        if book_folder.tracks.len() == 1 {
90            // Single file - just convert
91            self.ffmpeg
92                .convert_single_file(
93                    &book_folder.tracks[0].file_path,
94                    &output_path,
95                    quality,
96                    use_copy,
97                    self.encoder,
98                )
99                .await
100                .context("Failed to convert audio file")?;
101        } else if use_copy {
102            // Copy mode - can concatenate directly
103            let concat_file = temp_dir.join("concat.txt");
104            let file_refs: Vec<&Path> = book_folder
105                .tracks
106                .iter()
107                .map(|t| t.file_path.as_path())
108                .collect();
109            FFmpeg::create_concat_file(&file_refs, &concat_file)?;
110
111            self.ffmpeg
112                .concat_audio_files(
113                    &concat_file,
114                    &output_path,
115                    quality,
116                    use_copy,
117                    self.encoder,
118                )
119                .await
120                .context("Failed to concatenate audio files")?;
121        } else if self.enable_parallel_encoding && book_folder.tracks.len() > 1 {
122            // Transcode mode - encode files in parallel with throttling
123            let effective_limit = self.max_concurrent_files.min(book_folder.tracks.len());
124
125            tracing::info!(
126                "Using parallel encoding: {} files with max {} concurrent",
127                book_folder.tracks.len(),
128                effective_limit
129            );
130
131            // Create semaphore to limit concurrent file encodings
132            let semaphore = Arc::new(Semaphore::new(effective_limit));
133
134            // Step 1: Encode all files to AAC/M4A in parallel (with throttling)
135            let mut encoded_files = Vec::new();
136            let mut tasks = Vec::new();
137
138            for (i, track) in book_folder.tracks.iter().enumerate() {
139                let temp_output = temp_dir.join(format!("encoded_{:04}.m4a", i));
140                encoded_files.push(temp_output.clone());
141
142                let ffmpeg = self.ffmpeg.clone();
143                let input = track.file_path.clone();
144                let output = temp_output;
145                let quality = quality.clone();
146                let encoder = self.encoder;
147                let sem = Arc::clone(&semaphore);
148
149                // Spawn parallel encoding task with semaphore
150                let task = tokio::spawn(async move {
151                    // Acquire permit before encoding (blocks if limit reached)
152                    let _permit = sem.acquire().await.unwrap();
153
154                    ffmpeg
155                        .convert_single_file(&input, &output, &quality, false, encoder)
156                        .await
157                    // Permit automatically released when _permit drops
158                });
159
160                tasks.push(task);
161            }
162
163            // Wait for all encoding tasks to complete
164            for (i, task) in tasks.into_iter().enumerate() {
165                match task.await {
166                    Ok(Ok(())) => continue,
167                    Ok(Err(e)) => {
168                        return Err(e).context(format!("Track {} encoding failed", i));
169                    }
170                    Err(e) => {
171                        return Err(anyhow::anyhow!("Task {} panicked: {}", i, e));
172                    }
173                }
174            }
175
176            tracing::info!("All {} files encoded, now concatenating...", encoded_files.len());
177
178            // Step 2: Concatenate the encoded files (fast, no re-encoding)
179            let concat_file = temp_dir.join("concat.txt");
180            let file_refs: Vec<&Path> = encoded_files.iter().map(|p| p.as_path()).collect();
181            FFmpeg::create_concat_file(&file_refs, &concat_file)?;
182
183            self.ffmpeg
184                .concat_audio_files(
185                    &concat_file,
186                    &output_path,
187                    quality,
188                    true, // use copy mode for concatenation
189                    self.encoder,
190                )
191                .await
192                .context("Failed to concatenate encoded files")?;
193        } else {
194            // Serial mode - concatenate and encode in one FFmpeg call (traditional method)
195            tracing::info!("Using serial encoding (parallel encoding disabled in config)");
196
197            let concat_file = temp_dir.join("concat.txt");
198            let file_refs: Vec<&Path> = book_folder
199                .tracks
200                .iter()
201                .map(|t| t.file_path.as_path())
202                .collect();
203            FFmpeg::create_concat_file(&file_refs, &concat_file)?;
204
205            self.ffmpeg
206                .concat_audio_files(
207                    &concat_file,
208                    &output_path,
209                    quality,
210                    false, // transcode mode
211                    self.encoder,
212                )
213                .await
214                .context("Failed to concatenate audio files")?;
215        }
216
217        tracing::info!("Audio processing complete: {}", output_path.display());
218
219        // Step 3: Generate and inject chapters
220        let chapters = self.generate_chapters(book_folder, chapter_source)?;
221
222        if !chapters.is_empty() {
223            let chapters_file = temp_dir.join("chapters.txt");
224            write_mp4box_chapters(&chapters, &chapters_file)
225                .context("Failed to write chapter file")?;
226
227            inject_chapters_mp4box(&output_path, &chapters_file)
228                .await
229                .context("Failed to inject chapters")?;
230
231            tracing::info!("Injected {} chapters", chapters.len());
232        }
233
234        // Step 4: Inject metadata
235        let title = book_folder.get_album_title();
236        let artist = book_folder.get_album_artist();
237        let year = book_folder.get_year();
238        let genre = book_folder.get_genre();
239
240        inject_metadata_atomicparsley(
241            &output_path,
242            title.as_deref(),
243            artist.as_deref(),
244            title.as_deref(), // Use title as album
245            year,
246            genre.as_deref(),
247            book_folder.cover_file.as_deref(),
248        )
249        .await
250        .context("Failed to inject metadata")?;
251
252        tracing::info!("Metadata injection complete");
253
254        // Clean up temp directory
255        if !self.keep_temp {
256            if let Err(e) = std::fs::remove_dir_all(&temp_dir) {
257                tracing::warn!("Failed to remove temp directory: {}", e);
258            }
259        }
260
261        // Calculate processing time
262        let processing_time = start_time.elapsed().as_secs_f64();
263
264        // Return success result
265        Ok(result.success(output_path, processing_time, use_copy))
266    }
267
268    /// Generate chapters for the book
269    fn generate_chapters(
270        &self,
271        book_folder: &BookFolder,
272        chapter_source: &str,
273    ) -> Result<Vec<crate::audio::Chapter>> {
274        match chapter_source {
275            "cue" => {
276                // Use CUE file if available
277                if let Some(ref cue_file) = book_folder.cue_file {
278                    tracing::info!("Using CUE file for chapters: {}", cue_file.display());
279                    return parse_cue_file(cue_file);
280                }
281                Ok(Vec::new())
282            }
283            "files" | "auto" => {
284                // Generate chapters from files
285                if book_folder.tracks.len() > 1 {
286                    let files: Vec<&Path> = book_folder
287                        .tracks
288                        .iter()
289                        .map(|t| t.file_path.as_path())
290                        .collect();
291                    let durations: Vec<f64> = book_folder
292                        .tracks
293                        .iter()
294                        .map(|t| t.quality.duration)
295                        .collect();
296
297                    tracing::info!(
298                        "Generating {} chapters from files",
299                        book_folder.tracks.len()
300                    );
301                    Ok(generate_chapters_from_files(&files, &durations))
302                } else {
303                    // Single file - check for CUE
304                    if let Some(ref cue_file) = book_folder.cue_file {
305                        tracing::info!("Using CUE file for single-file book");
306                        parse_cue_file(cue_file)
307                    } else {
308                        Ok(Vec::new())
309                    }
310                }
311            }
312            "none" => Ok(Vec::new()),
313            _ => {
314                tracing::warn!("Unknown chapter source: {}, using auto", chapter_source);
315                self.generate_chapters(book_folder, "auto")
316            }
317        }
318    }
319
320    /// Create temporary directory for processing
321    fn create_temp_dir(&self, book_name: &str) -> Result<PathBuf> {
322        let temp_base = std::env::temp_dir();
323        let sanitized_name = sanitize_filename::sanitize(book_name);
324        let temp_dir = temp_base.join(format!("audiobook-forge-{}", sanitized_name));
325
326        if temp_dir.exists() {
327            std::fs::remove_dir_all(&temp_dir).ok();
328        }
329
330        std::fs::create_dir_all(&temp_dir).context("Failed to create temp directory")?;
331
332        Ok(temp_dir)
333    }
334}
335
336impl Default for Processor {
337    fn default() -> Self {
338        Self::new().expect("Failed to create processor")
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn test_processor_creation() {
348        let processor = Processor::new();
349        assert!(processor.is_ok());
350    }
351
352    #[test]
353    fn test_processor_with_options() {
354        let processor = Processor::with_options(true, AacEncoder::AppleSilicon, true, 8).unwrap();
355        assert!(processor.keep_temp);
356        assert_eq!(processor.encoder, AacEncoder::AppleSilicon);
357        assert_eq!(processor.max_concurrent_files, 8);
358    }
359
360    #[test]
361    fn test_create_temp_dir() {
362        let processor = Processor::new().unwrap();
363        let temp_dir = processor.create_temp_dir("Test Book").unwrap();
364
365        assert!(temp_dir.exists());
366        assert!(temp_dir.to_string_lossy().contains("audiobook-forge"));
367
368        // Clean up
369        std::fs::remove_dir_all(temp_dir).ok();
370    }
371}