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