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