use super::{QualityProfile, Track, AudibleMetadata};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BookCase {
A,
B,
C,
D,
E,
}
impl BookCase {
pub fn as_str(&self) -> &'static str {
match self {
BookCase::A => "A",
BookCase::B => "B",
BookCase::C => "C",
BookCase::D => "D",
BookCase::E => "E",
}
}
}
impl std::fmt::Display for BookCase {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Case {}", self.as_str())
}
}
#[derive(Debug, Clone)]
pub struct BookFolder {
pub folder_path: PathBuf,
pub name: String,
pub case: BookCase,
pub tracks: Vec<Track>,
pub mp3_files: Vec<PathBuf>,
pub m4b_files: Vec<PathBuf>,
pub cover_file: Option<PathBuf>,
pub cue_file: Option<PathBuf>,
pub audible_metadata: Option<AudibleMetadata>,
pub detected_asin: Option<String>,
pub merge_pattern_detected: bool,
}
impl BookFolder {
pub fn new(folder_path: PathBuf) -> Self {
let name = folder_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
Self {
folder_path,
name,
case: BookCase::D,
tracks: Vec::new(),
mp3_files: Vec::new(),
m4b_files: Vec::new(),
cover_file: None,
cue_file: None,
audible_metadata: None,
detected_asin: None,
merge_pattern_detected: false,
}
}
pub fn classify(&mut self) {
use crate::utils::detect_merge_pattern;
let mp3_count = self.mp3_files.len();
let m4b_count = self.m4b_files.len();
self.case = if m4b_count > 1 {
let paths: Vec<&std::path::Path> = self.m4b_files.iter().map(|p| p.as_path()).collect();
let pattern_result = detect_merge_pattern(&paths);
self.merge_pattern_detected = pattern_result.pattern_detected;
if pattern_result.pattern_detected {
BookCase::E
} else {
BookCase::C
}
} else if m4b_count == 1 {
BookCase::C
} else if mp3_count > 1 {
BookCase::A
} else if mp3_count == 1 {
BookCase::B
} else {
BookCase::D
};
}
pub fn get_total_duration(&self) -> f64 {
self.tracks.iter().map(|t| t.quality.duration).sum()
}
pub fn get_best_quality_profile(&self, prefer_stereo: bool) -> Option<&QualityProfile> {
if self.tracks.is_empty() {
return None;
}
let mut best = &self.tracks[0].quality;
for track in &self.tracks[1..] {
if track.quality.is_better_than(best, prefer_stereo) {
best = &track.quality;
}
}
Some(best)
}
pub fn can_use_concat_copy(&self) -> bool {
if self.tracks.is_empty() {
return false;
}
let first_codec = self.tracks[0].quality.codec.to_lowercase();
if first_codec == "mp3" || first_codec == "mp3float" {
return false;
}
if self.tracks.len() <= 1 {
return true;
}
let first = &self.tracks[0].quality;
self.tracks[1..]
.iter()
.all(|t| first.is_compatible_for_concat(&t.quality))
}
pub fn get_output_filename(&self) -> String {
format!("{}.m4b", self.name)
}
pub fn estimate_output_size(&self) -> u64 {
let duration = self.get_total_duration();
if let Some(quality) = self.get_best_quality_profile(true) {
((quality.bitrate as f64 * duration * 1000.0) / 8.0) as u64
} else {
0
}
}
pub fn is_processable(&self) -> bool {
matches!(self.case, BookCase::A | BookCase::B | BookCase::E)
}
pub fn get_album_artist(&self) -> Option<String> {
self.tracks
.iter()
.find_map(|t| t.album_artist.clone().or_else(|| t.artist.clone()))
}
pub fn get_album_title(&self) -> Option<String> {
self.tracks
.iter()
.find_map(|t| t.album.clone())
.or_else(|| Some(self.name.clone()))
}
pub fn get_year(&self) -> Option<u32> {
self.tracks.iter().find_map(|t| t.year)
}
pub fn get_genre(&self) -> Option<String> {
self.tracks.iter().find_map(|t| t.genre.clone())
}
pub fn get_comment(&self) -> Option<String> {
self.tracks.iter().find_map(|t| t.comment.clone())
}
pub fn get_composer(&self) -> Option<String> {
self.tracks.iter().find_map(|t| t.composer.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_book_case_display() {
assert_eq!(BookCase::A.to_string(), "Case A");
assert_eq!(BookCase::B.to_string(), "Case B");
assert_eq!(BookCase::C.to_string(), "Case C");
assert_eq!(BookCase::D.to_string(), "Case D");
assert_eq!(BookCase::E.to_string(), "Case E");
}
#[test]
fn test_book_folder_creation() {
let book = BookFolder::new(PathBuf::from("/path/to/My Book"));
assert_eq!(book.name, "My Book");
assert_eq!(book.case, BookCase::D);
assert!(!book.merge_pattern_detected);
}
#[test]
fn test_book_folder_classification() {
let mut book = BookFolder::new(PathBuf::from("/path/to/book"));
book.mp3_files = vec![
PathBuf::from("1.mp3"),
PathBuf::from("2.mp3"),
];
book.classify();
assert_eq!(book.case, BookCase::A);
book.mp3_files = vec![PathBuf::from("1.mp3")];
book.classify();
assert_eq!(book.case, BookCase::B);
book.m4b_files = vec![PathBuf::from("book.m4b")];
book.classify();
assert_eq!(book.case, BookCase::C);
book.mp3_files.clear();
book.m4b_files.clear();
book.classify();
assert_eq!(book.case, BookCase::D);
book.m4b_files = vec![
PathBuf::from("Book Part 1.m4b"),
PathBuf::from("Book Part 2.m4b"),
];
book.classify();
assert_eq!(book.case, BookCase::E);
assert!(book.merge_pattern_detected);
book.m4b_files = vec![
PathBuf::from("Completely Different.m4b"),
PathBuf::from("Another Book.m4b"),
];
book.classify();
assert_eq!(book.case, BookCase::C);
assert!(!book.merge_pattern_detected);
}
#[test]
fn test_can_use_concat_copy() {
let mut book = BookFolder::new(PathBuf::from("/path/to/book"));
let quality1 = QualityProfile::new(128, 44100, 2, "aac".to_string(), 3600.0).unwrap();
let quality2 = QualityProfile::new(128, 44100, 2, "aac".to_string(), 1800.0).unwrap();
book.tracks = vec![
Track::new(PathBuf::from("1.m4a"), quality1),
Track::new(PathBuf::from("2.m4a"), quality2),
];
assert!(book.can_use_concat_copy());
let mp3_quality1 = QualityProfile::new(128, 44100, 2, "mp3".to_string(), 3600.0).unwrap();
let mp3_quality2 = QualityProfile::new(128, 44100, 2, "mp3".to_string(), 1800.0).unwrap();
book.tracks = vec![
Track::new(PathBuf::from("1.mp3"), mp3_quality1),
Track::new(PathBuf::from("2.mp3"), mp3_quality2),
];
assert!(!book.can_use_concat_copy());
}
}