1use super::{QualityProfile, Track, AudibleMetadata};
4use serde::{Deserialize, Serialize};
5use std::path::PathBuf;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9pub enum BookCase {
10 A,
12 B,
14 C,
16 D,
18 E,
20}
21
22impl BookCase {
23 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#[derive(Debug, Clone)]
43pub struct BookFolder {
44 pub folder_path: PathBuf,
46 pub name: String,
48 pub case: BookCase,
50 pub tracks: Vec<Track>,
52 pub mp3_files: Vec<PathBuf>,
54 pub m4b_files: Vec<PathBuf>,
56 pub cover_file: Option<PathBuf>,
58 pub cue_file: Option<PathBuf>,
60 pub audible_metadata: Option<AudibleMetadata>,
62 pub detected_asin: Option<String>,
64 pub merge_pattern_detected: bool,
66}
67
68impl BookFolder {
69 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 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 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 pub fn get_total_duration(&self) -> f64 {
123 self.tracks.iter().map(|t| t.quality.duration).sum()
124 }
125
126 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 pub fn can_use_concat_copy(&self) -> bool {
143 if self.tracks.is_empty() {
144 return false;
145 }
146
147 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 pub fn get_output_filename(&self) -> String {
165 format!("{}.m4b", self.name)
166 }
167
168 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 ((quality.bitrate as f64 * duration * 1000.0) / 8.0) as u64
174 } else {
175 0
176 }
177 }
178
179 pub fn is_processable(&self) -> bool {
181 matches!(self.case, BookCase::A | BookCase::B | BookCase::E)
182 }
183
184 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 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 pub fn get_year(&self) -> Option<u32> {
201 self.tracks.iter().find_map(|t| t.year)
202 }
203
204 pub fn get_genre(&self) -> Option<String> {
206 self.tracks.iter().find_map(|t| t.genre.clone())
207 }
208
209 pub fn get_comment(&self) -> Option<String> {
211 self.tracks.iter().find_map(|t| t.comment.clone())
212 }
213
214 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 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 book.mp3_files = vec![PathBuf::from("1.mp3")];
255 book.classify();
256 assert_eq!(book.case, BookCase::B);
257
258 book.m4b_files = vec![PathBuf::from("book.m4b")];
260 book.classify();
261 assert_eq!(book.case, BookCase::C);
262
263 book.mp3_files.clear();
265 book.m4b_files.clear();
266 book.classify();
267 assert_eq!(book.case, BookCase::D);
268
269 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 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 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 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}