audiobook_forge/models/
book.rs1use 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}
19
20impl BookCase {
21 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#[derive(Debug, Clone)]
40pub struct BookFolder {
41 pub folder_path: PathBuf,
43 pub name: String,
45 pub case: BookCase,
47 pub tracks: Vec<Track>,
49 pub mp3_files: Vec<PathBuf>,
51 pub m4b_files: Vec<PathBuf>,
53 pub cover_file: Option<PathBuf>,
55 pub cue_file: Option<PathBuf>,
57 pub audible_metadata: Option<AudibleMetadata>,
59 pub detected_asin: Option<String>,
61}
62
63impl BookFolder {
64 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 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 pub fn get_total_duration(&self) -> f64 {
104 self.tracks.iter().map(|t| t.quality.duration).sum()
105 }
106
107 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 pub fn can_use_concat_copy(&self) -> bool {
124 if self.tracks.is_empty() {
125 return false;
126 }
127
128 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 pub fn get_output_filename(&self) -> String {
146 format!("{}.m4b", self.name)
147 }
148
149 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 ((quality.bitrate as f64 * duration * 1000.0) / 8.0) as u64
155 } else {
156 0
157 }
158 }
159
160 pub fn is_processable(&self) -> bool {
162 matches!(self.case, BookCase::A | BookCase::B)
163 }
164
165 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 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 pub fn get_year(&self) -> Option<u32> {
182 self.tracks.iter().find_map(|t| t.year)
183 }
184
185 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 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 book.mp3_files = vec![PathBuf::from("1.mp3")];
224 book.classify();
225 assert_eq!(book.case, BookCase::B);
226
227 book.m4b_files = vec![PathBuf::from("book.m4b")];
229 book.classify();
230 assert_eq!(book.case, BookCase::C);
231
232 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 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 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}