1use 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
12pub struct Processor {
14 ffmpeg: FFmpeg,
15 keep_temp: bool,
16 use_apple_silicon: bool,
17 enable_parallel_encoding: bool,
18}
19
20impl Processor {
21 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 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 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 if !output_dir.exists() {
53 std::fs::create_dir_all(output_dir)
54 .context("Failed to create output directory")?;
55 }
56
57 let output_filename = book_folder.get_output_filename();
59 let output_path = output_dir.join(&output_filename);
60
61 let temp_dir = self.create_temp_dir(&book_folder.name)?;
63
64 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 let quality = book_folder
76 .get_best_quality_profile(true)
77 .context("No tracks found")?;
78
79 if book_folder.tracks.len() == 1 {
80 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 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 tracing::info!(
114 "Using parallel encoding: {} files will be encoded concurrently",
115 book_folder.tracks.len()
116 );
117
118 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 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 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 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, self.use_apple_silicon,
163 )
164 .await
165 .context("Failed to concatenate encoded files")?;
166 } else {
167 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, 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 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 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(), 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 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 let processing_time = start_time.elapsed().as_secs_f64();
236
237 Ok(result.success(output_path, processing_time, use_copy))
239 }
240
241 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 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 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 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 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 std::fs::remove_dir_all(temp_dir).ok();
342 }
343}