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
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 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 book.mp3_files = vec![PathBuf::from("1.mp3")];
245 book.classify();
246 assert_eq!(book.case, BookCase::B);
247
248 book.m4b_files = vec![PathBuf::from("book.m4b")];
250 book.classify();
251 assert_eq!(book.case, BookCase::C);
252
253 book.mp3_files.clear();
255 book.m4b_files.clear();
256 book.classify();
257 assert_eq!(book.case, BookCase::D);
258
259 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 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 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 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}