Skip to main content

audiobook_forge/models/
book.rs

1//! Audiobook folder model
2
3use super::{QualityProfile, Track, AudibleMetadata};
4use serde::{Deserialize, Serialize};
5use std::path::PathBuf;
6
7/// Classification of audiobook folders based on their contents
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9pub enum BookCase {
10    /// Case A: Folder with multiple MP3 files (needs processing)
11    A,
12    /// Case B: Folder with single MP3 file (needs processing)
13    B,
14    /// Case C: Folder with existing M4B file (may skip or normalize)
15    C,
16    /// Case D: Unknown or invalid folder structure
17    D,
18    /// Case E: Multiple M4B files that can be merged losslessly
19    E,
20}
21
22impl BookCase {
23    /// Convert case to string representation
24    pub fn as_str(&self) -> &'static str {
25        match self {
26            BookCase::A => "A",
27            BookCase::B => "B",
28            BookCase::C => "C",
29            BookCase::D => "D",
30            BookCase::E => "E",
31        }
32    }
33}
34
35impl std::fmt::Display for BookCase {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        write!(f, "Case {}", self.as_str())
38    }
39}
40
41/// Represents an audiobook folder with its contents and metadata
42#[derive(Debug, Clone)]
43pub struct BookFolder {
44    /// Path to the folder
45    pub folder_path: PathBuf,
46    /// Folder name (used as book title)
47    pub name: String,
48    /// Classification case
49    pub case: BookCase,
50    /// List of audio tracks found
51    pub tracks: Vec<Track>,
52    /// MP3 files found (before analysis)
53    pub mp3_files: Vec<PathBuf>,
54    /// M4B files found
55    pub m4b_files: Vec<PathBuf>,
56    /// Cover art file path
57    pub cover_file: Option<PathBuf>,
58    /// CUE file path (if present)
59    pub cue_file: Option<PathBuf>,
60    /// Audible metadata (if fetched)
61    pub audible_metadata: Option<AudibleMetadata>,
62    /// Detected ASIN from folder name or metadata
63    pub detected_asin: Option<String>,
64    /// Whether a merge pattern was detected for multiple M4B files
65    pub merge_pattern_detected: bool,
66}
67
68impl BookFolder {
69    /// Create a new BookFolder from a path
70    pub fn new(folder_path: PathBuf) -> Self {
71        let name = folder_path
72            .file_name()
73            .and_then(|s| s.to_str())
74            .unwrap_or("unknown")
75            .to_string();
76
77        Self {
78            folder_path,
79            name,
80            case: BookCase::D,
81            tracks: Vec::new(),
82            mp3_files: Vec::new(),
83            m4b_files: Vec::new(),
84            cover_file: None,
85            cue_file: None,
86            audible_metadata: None,
87            detected_asin: None,
88            merge_pattern_detected: false,
89        }
90    }
91
92    /// Classify the folder based on its contents
93    pub fn classify(&mut self) {
94        use crate::utils::detect_merge_pattern;
95
96        let mp3_count = self.mp3_files.len();
97        let m4b_count = self.m4b_files.len();
98
99        self.case = if m4b_count > 1 {
100            // Check if multiple M4B files match a merge pattern
101            let paths: Vec<&std::path::Path> = self.m4b_files.iter().map(|p| p.as_path()).collect();
102            let pattern_result = detect_merge_pattern(&paths);
103            self.merge_pattern_detected = pattern_result.pattern_detected;
104
105            if pattern_result.pattern_detected {
106                BookCase::E
107            } else {
108                BookCase::C
109            }
110        } else if m4b_count == 1 {
111            BookCase::C
112        } else if mp3_count > 1 {
113            BookCase::A
114        } else if mp3_count == 1 {
115            BookCase::B
116        } else {
117            BookCase::D
118        };
119    }
120
121    /// Get total duration of all tracks in seconds
122    pub fn get_total_duration(&self) -> f64 {
123        self.tracks.iter().map(|t| t.quality.duration).sum()
124    }
125
126    /// Get the best quality profile among all tracks
127    pub fn get_best_quality_profile(&self, prefer_stereo: bool) -> Option<&QualityProfile> {
128        if self.tracks.is_empty() {
129            return None;
130        }
131
132        let mut best = &self.tracks[0].quality;
133        for track in &self.tracks[1..] {
134            if track.quality.is_better_than(best, prefer_stereo) {
135                best = &track.quality;
136            }
137        }
138        Some(best)
139    }
140
141    /// Check if all tracks can be concatenated without re-encoding (copy mode)
142    pub fn can_use_concat_copy(&self) -> bool {
143        if self.tracks.is_empty() {
144            return false;
145        }
146
147        // MP3 codec cannot be copied into M4B container - must transcode to AAC
148        let first_codec = self.tracks[0].quality.codec.to_lowercase();
149        if first_codec == "mp3" || first_codec == "mp3float" {
150            return false;
151        }
152
153        if self.tracks.len() <= 1 {
154            return true;
155        }
156
157        let first = &self.tracks[0].quality;
158        self.tracks[1..]
159            .iter()
160            .all(|t| first.is_compatible_for_concat(&t.quality))
161    }
162
163    /// Get output filename for the M4B file
164    pub fn get_output_filename(&self) -> String {
165        format!("{}.m4b", self.name)
166    }
167
168    /// Get estimated file size in bytes (rough estimate)
169    pub fn estimate_output_size(&self) -> u64 {
170        let duration = self.get_total_duration();
171        if let Some(quality) = self.get_best_quality_profile(true) {
172            // bitrate (kbps) * duration (s) * 1000 / 8 = bytes
173            ((quality.bitrate as f64 * duration * 1000.0) / 8.0) as u64
174        } else {
175            0
176        }
177    }
178
179    /// Check if folder is processable (Case A, B, or E)
180    pub fn is_processable(&self) -> bool {
181        matches!(self.case, BookCase::A | BookCase::B | BookCase::E)
182    }
183
184    /// Get album artist from tracks (first non-None value)
185    pub fn get_album_artist(&self) -> Option<String> {
186        self.tracks
187            .iter()
188            .find_map(|t| t.album_artist.clone().or_else(|| t.artist.clone()))
189    }
190
191    /// Get album title from tracks (first non-None value)
192    pub fn get_album_title(&self) -> Option<String> {
193        self.tracks
194            .iter()
195            .find_map(|t| t.album.clone())
196            .or_else(|| Some(self.name.clone()))
197    }
198
199    /// Get year from tracks (first non-None value)
200    pub fn get_year(&self) -> Option<u32> {
201        self.tracks.iter().find_map(|t| t.year)
202    }
203
204    /// Get genre from tracks (first non-None value)
205    pub fn get_genre(&self) -> Option<String> {
206        self.tracks.iter().find_map(|t| t.genre.clone())
207    }
208
209    /// Get comment from tracks (first non-None value)
210    pub fn get_comment(&self) -> Option<String> {
211        self.tracks.iter().find_map(|t| t.comment.clone())
212    }
213
214    /// Get composer from tracks (first non-None value)
215    pub fn get_composer(&self) -> Option<String> {
216        self.tracks.iter().find_map(|t| t.composer.clone())
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn test_book_case_display() {
226        assert_eq!(BookCase::A.to_string(), "Case A");
227        assert_eq!(BookCase::B.to_string(), "Case B");
228        assert_eq!(BookCase::C.to_string(), "Case C");
229        assert_eq!(BookCase::D.to_string(), "Case D");
230        assert_eq!(BookCase::E.to_string(), "Case E");
231    }
232
233    #[test]
234    fn test_book_folder_creation() {
235        let book = BookFolder::new(PathBuf::from("/path/to/My Book"));
236        assert_eq!(book.name, "My Book");
237        assert_eq!(book.case, BookCase::D);
238        assert!(!book.merge_pattern_detected);
239    }
240
241    #[test]
242    fn test_book_folder_classification() {
243        let mut book = BookFolder::new(PathBuf::from("/path/to/book"));
244
245        // Case A: multiple MP3s
246        book.mp3_files = vec![
247            PathBuf::from("1.mp3"),
248            PathBuf::from("2.mp3"),
249        ];
250        book.classify();
251        assert_eq!(book.case, BookCase::A);
252
253        // Case B: single MP3
254        book.mp3_files = vec![PathBuf::from("1.mp3")];
255        book.classify();
256        assert_eq!(book.case, BookCase::B);
257
258        // Case C: M4B present
259        book.m4b_files = vec![PathBuf::from("book.m4b")];
260        book.classify();
261        assert_eq!(book.case, BookCase::C);
262
263        // Case D: no audio files
264        book.mp3_files.clear();
265        book.m4b_files.clear();
266        book.classify();
267        assert_eq!(book.case, BookCase::D);
268
269        // Case E: multiple M4B files with merge pattern
270        book.m4b_files = vec![
271            PathBuf::from("Book Part 1.m4b"),
272            PathBuf::from("Book Part 2.m4b"),
273        ];
274        book.classify();
275        assert_eq!(book.case, BookCase::E);
276        assert!(book.merge_pattern_detected);
277
278        // Case C: multiple M4B files without merge pattern
279        book.m4b_files = vec![
280            PathBuf::from("Completely Different.m4b"),
281            PathBuf::from("Another Book.m4b"),
282        ];
283        book.classify();
284        assert_eq!(book.case, BookCase::C);
285        assert!(!book.merge_pattern_detected);
286    }
287
288    #[test]
289    fn test_can_use_concat_copy() {
290        let mut book = BookFolder::new(PathBuf::from("/path/to/book"));
291
292        // Test with AAC/M4A files (can use concat copy)
293        let quality1 = QualityProfile::new(128, 44100, 2, "aac".to_string(), 3600.0).unwrap();
294        let quality2 = QualityProfile::new(128, 44100, 2, "aac".to_string(), 1800.0).unwrap();
295
296        book.tracks = vec![
297            Track::new(PathBuf::from("1.m4a"), quality1),
298            Track::new(PathBuf::from("2.m4a"), quality2),
299        ];
300
301        assert!(book.can_use_concat_copy());
302
303        // Test with MP3 files (cannot use concat copy - must transcode)
304        let mp3_quality1 = QualityProfile::new(128, 44100, 2, "mp3".to_string(), 3600.0).unwrap();
305        let mp3_quality2 = QualityProfile::new(128, 44100, 2, "mp3".to_string(), 1800.0).unwrap();
306
307        book.tracks = vec![
308            Track::new(PathBuf::from("1.mp3"), mp3_quality1),
309            Track::new(PathBuf::from("2.mp3"), mp3_quality2),
310        ];
311
312        assert!(!book.can_use_concat_copy());
313    }
314}