audiobook_forge/core/
analyzer.rs

1//! Audio track analyzer
2
3use crate::audio::{extract_metadata, FFmpeg};
4use crate::models::{BookFolder, Track};
5use anyhow::Result;
6use futures::stream::{self, StreamExt};
7
8/// Analyzer for audio tracks
9pub struct Analyzer {
10    ffmpeg: FFmpeg,
11    parallel_workers: usize,
12}
13
14impl Analyzer {
15    /// Create a new analyzer
16    pub fn new() -> Result<Self> {
17        Ok(Self {
18            ffmpeg: FFmpeg::new()?,
19            parallel_workers: 8,
20        })
21    }
22
23    /// Create analyzer with custom parallel workers
24    pub fn with_workers(workers: usize) -> Result<Self> {
25        Ok(Self {
26            ffmpeg: FFmpeg::new()?,
27            parallel_workers: workers.clamp(1, 16),
28        })
29    }
30
31    /// Analyze all MP3 files in a book folder
32    pub async fn analyze_book_folder(&self, book_folder: &mut BookFolder) -> Result<()> {
33        // Analyze all MP3 files in parallel
34        let results = stream::iter(&book_folder.mp3_files)
35            .map(|mp3_file| async {
36                // Probe audio file
37                let quality = self.ffmpeg.probe_audio_file(mp3_file).await?;
38
39                // Create track
40                let mut track = Track::new(mp3_file.clone(), quality);
41
42                // Extract metadata
43                if let Err(e) = extract_metadata(&mut track) {
44                    tracing::warn!(
45                        "Failed to extract metadata from {}: {}",
46                        mp3_file.display(),
47                        e
48                    );
49                }
50
51                Ok::<Track, anyhow::Error>(track)
52            })
53            .buffer_unordered(self.parallel_workers)
54            .collect::<Vec<_>>()
55            .await;
56
57        // Collect successful tracks
58        let mut tracks = Vec::new();
59        for result in results {
60            match result {
61                Ok(track) => tracks.push(track),
62                Err(e) => {
63                    tracing::error!("Failed to analyze track: {}", e);
64                    return Err(e);
65                }
66            }
67        }
68
69        // Sort tracks by filename (they should already be sorted from scanner)
70        // This is just to ensure consistency
71        tracks.sort_by(|a, b| a.file_path.cmp(&b.file_path));
72
73        book_folder.tracks = tracks;
74
75        Ok(())
76    }
77
78    /// Get total duration of book folder in seconds
79    pub fn get_total_duration(&self, book_folder: &BookFolder) -> f64 {
80        book_folder.get_total_duration()
81    }
82
83    /// Check if book can use copy mode (all tracks compatible)
84    pub fn can_use_copy_mode(&self, book_folder: &BookFolder) -> bool {
85        book_folder.can_use_concat_copy()
86    }
87}
88
89impl Default for Analyzer {
90    fn default() -> Self {
91        Self::new().expect("Failed to create analyzer")
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::models::QualityProfile;
99    use std::path::PathBuf;
100
101    #[test]
102    fn test_analyzer_creation() {
103        let analyzer = Analyzer::new();
104        assert!(analyzer.is_ok());
105    }
106
107    #[test]
108    fn test_analyzer_with_workers() {
109        let analyzer = Analyzer::with_workers(4).unwrap();
110        assert_eq!(analyzer.parallel_workers, 4);
111
112        // Test clamping
113        let analyzer = Analyzer::with_workers(20).unwrap();
114        assert_eq!(analyzer.parallel_workers, 16);
115    }
116
117    #[test]
118    fn test_can_use_copy_mode() {
119        let analyzer = Analyzer::new().unwrap();
120        let mut book = BookFolder::new(PathBuf::from("/test"));
121
122        // Test with AAC/M4A files (can use copy mode)
123        let aac_quality = QualityProfile::new(128, 44100, 2, "aac".to_string(), 3600.0).unwrap();
124        book.tracks = vec![
125            Track::new(PathBuf::from("1.m4a"), aac_quality.clone()),
126            Track::new(PathBuf::from("2.m4a"), aac_quality),
127        ];
128
129        assert!(analyzer.can_use_copy_mode(&book));
130
131        // Test with MP3 files (cannot use copy mode - must transcode)
132        let mp3_quality = QualityProfile::new(128, 44100, 2, "mp3".to_string(), 3600.0).unwrap();
133        book.tracks = vec![
134            Track::new(PathBuf::from("1.mp3"), mp3_quality.clone()),
135            Track::new(PathBuf::from("2.mp3"), mp3_quality),
136        ];
137
138        assert!(!analyzer.can_use_copy_mode(&book));
139    }
140}