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}
22
23impl Processor {
24 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 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 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 if !output_dir.exists() {
63 std::fs::create_dir_all(output_dir)
64 .context("Failed to create output directory")?;
65 }
66
67 let output_filename = book_folder.get_output_filename();
69 let output_path = output_dir.join(&output_filename);
70
71 let temp_dir = self.create_temp_dir(&book_folder.name)?;
73
74 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 let quality = book_folder
86 .get_best_quality_profile(true)
87 .context("No tracks found")?;
88
89 if book_folder.tracks.len() == 1 {
90 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 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 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 let semaphore = Arc::new(Semaphore::new(effective_limit));
133
134 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 let task = tokio::spawn(async move {
151 let _permit = sem.acquire().await.unwrap();
153
154 ffmpeg
155 .convert_single_file(&input, &output, &quality, false, encoder)
156 .await
157 });
159
160 tasks.push(task);
161 }
162
163 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 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, self.encoder,
190 )
191 .await
192 .context("Failed to concatenate encoded files")?;
193 } else {
194 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, 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 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 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(), 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 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 let processing_time = start_time.elapsed().as_secs_f64();
263
264 Ok(result.success(output_path, processing_time, use_copy))
266 }
267
268 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 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 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 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 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 std::fs::remove_dir_all(temp_dir).ok();
370 }
371}