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
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn test_book_case_display() {
216        assert_eq!(BookCase::A.to_string(), "Case A");
217        assert_eq!(BookCase::B.to_string(), "Case B");
218        assert_eq!(BookCase::C.to_string(), "Case C");
219        assert_eq!(BookCase::D.to_string(), "Case D");
220        assert_eq!(BookCase::E.to_string(), "Case E");
221    }
222
223    #[test]
224    fn test_book_folder_creation() {
225        let book = BookFolder::new(PathBuf::from("/path/to/My Book"));
226        assert_eq!(book.name, "My Book");
227        assert_eq!(book.case, BookCase::D);
228        assert!(!book.merge_pattern_detected);
229    }
230
231    #[test]
232    fn test_book_folder_classification() {
233        let mut book = BookFolder::new(PathBuf::from("/path/to/book"));
234
235        // Case A: multiple MP3s
236        book.mp3_files = vec![
237            PathBuf::from("1.mp3"),
238            PathBuf::from("2.mp3"),
239        ];
240        book.classify();
241        assert_eq!(book.case, BookCase::A);
242
243        // Case B: single MP3
244        book.mp3_files = vec![PathBuf::from("1.mp3")];
245        book.classify();
246        assert_eq!(book.case, BookCase::B);
247
248        // Case C: M4B present
249        book.m4b_files = vec![PathBuf::from("book.m4b")];
250        book.classify();
251        assert_eq!(book.case, BookCase::C);
252
253        // Case D: no audio files
254        book.mp3_files.clear();
255        book.m4b_files.clear();
256        book.classify();
257        assert_eq!(book.case, BookCase::D);
258
259        // Case E: multiple M4B files with merge pattern
260        book.m4b_files = vec![
261            PathBuf::from("Book Part 1.m4b"),
262            PathBuf::from("Book Part 2.m4b"),
263        ];
264        book.classify();
265        assert_eq!(book.case, BookCase::E);
266        assert!(book.merge_pattern_detected);
267
268        // Case C: multiple M4B files without merge pattern
269        book.m4b_files = vec![
270            PathBuf::from("Completely Different.m4b"),
271            PathBuf::from("Another Book.m4b"),
272        ];
273        book.classify();
274        assert_eq!(book.case, BookCase::C);
275        assert!(!book.merge_pattern_detected);
276    }
277
278    #[test]
279    fn test_can_use_concat_copy() {
280        let mut book = BookFolder::new(PathBuf::from("/path/to/book"));
281
282        // Test with AAC/M4A files (can use concat copy)
283        let quality1 = QualityProfile::new(128, 44100, 2, "aac".to_string(), 3600.0).unwrap();
284        let quality2 = QualityProfile::new(128, 44100, 2, "aac".to_string(), 1800.0).unwrap();
285
286        book.tracks = vec![
287            Track::new(PathBuf::from("1.m4a"), quality1),
288            Track::new(PathBuf::from("2.m4a"), quality2),
289        ];
290
291        assert!(book.can_use_concat_copy());
292
293        // Test with MP3 files (cannot use concat copy - must transcode)
294        let mp3_quality1 = QualityProfile::new(128, 44100, 2, "mp3".to_string(), 3600.0).unwrap();
295        let mp3_quality2 = QualityProfile::new(128, 44100, 2, "mp3".to_string(), 1800.0).unwrap();
296
297        book.tracks = vec![
298            Track::new(PathBuf::from("1.mp3"), mp3_quality1),
299            Track::new(PathBuf::from("2.mp3"), mp3_quality2),
300        ];
301
302        assert!(!book.can_use_concat_copy());
303    }
304}