audiobook_forge/models/
match_models.rs

1//! Match models for interactive metadata matching
2
3use super::AudibleMetadata;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7/// Confidence level for match quality (similar to BEETS Recommendation)
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9pub enum MatchConfidence {
10    /// Strong match (>96% - distance < 0.04)
11    Strong,
12    /// Medium match (88-96% - distance < 0.12)
13    Medium,
14    /// Low match (80-88% - distance < 0.20)
15    Low,
16    /// Weak or no clear match (<80%)
17    None,
18}
19
20/// Distance/penalty calculation for metadata comparison
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct MetadataDistance {
23    /// Individual penalties by field name
24    penalties: HashMap<String, f64>,
25    /// Weighted total distance (0.0 = perfect, 1.0 = worst)
26    total: f64,
27}
28
29impl MetadataDistance {
30    /// Create a new empty distance
31    pub fn new() -> Self {
32        Self {
33            penalties: HashMap::new(),
34            total: 0.0,
35        }
36    }
37
38    /// Add a weighted penalty for a specific field
39    pub fn add_penalty(&mut self, field: &str, distance: f64, weight: f64) {
40        let weighted = distance * weight;
41        self.penalties.insert(field.to_string(), distance);
42        self.total += weighted;
43    }
44
45    /// Get total weighted distance
46    pub fn total_distance(&self) -> f64 {
47        self.total
48    }
49
50    /// Get penalty for a specific field
51    pub fn get_penalty(&self, field: &str) -> Option<f64> {
52        self.penalties.get(field).copied()
53    }
54
55    /// Get all field penalties
56    pub fn penalties(&self) -> &HashMap<String, f64> {
57        &self.penalties
58    }
59}
60
61impl Default for MetadataDistance {
62    fn default() -> Self {
63        Self::new()
64    }
65}
66
67/// Match candidate with distance scoring
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct MatchCandidate {
70    /// Distance/penalty score
71    pub distance: MetadataDistance,
72    /// Audible metadata
73    pub metadata: AudibleMetadata,
74    /// Confidence level
75    pub confidence: MatchConfidence,
76}
77
78/// Source of metadata extraction
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80pub enum MetadataSource {
81    /// Extracted from embedded M4B tags
82    Embedded,
83    /// Parsed from filename
84    Filename,
85    /// Manually provided by user
86    Manual,
87}
88
89/// Current metadata extracted from M4B file
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct CurrentMetadata {
92    /// Book title
93    pub title: Option<String>,
94    /// Author name (from artist/album_artist)
95    pub author: Option<String>,
96    /// Publication year
97    pub year: Option<u32>,
98    /// Duration in seconds
99    pub duration: Option<f64>,
100    /// Source of this metadata
101    pub source: MetadataSource,
102}
103
104impl CurrentMetadata {
105    /// Check if metadata is sufficient for searching
106    pub fn is_sufficient(&self) -> bool {
107        self.title.is_some() || self.author.is_some()
108    }
109
110    /// Merge with another CurrentMetadata (self takes priority)
111    pub fn merge_with(self, other: CurrentMetadata) -> CurrentMetadata {
112        CurrentMetadata {
113            title: self.title.or(other.title),
114            author: self.author.or(other.author),
115            year: self.year.or(other.year),
116            duration: self.duration.or(other.duration),
117            source: self.source,  // Keep original source
118        }
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn test_metadata_distance() {
128        let mut distance = MetadataDistance::new();
129        distance.add_penalty("title", 0.1, 0.4);  // 0.04 weighted
130        distance.add_penalty("author", 0.2, 0.3); // 0.06 weighted
131
132        assert!((distance.total_distance() - 0.10).abs() < 0.001);
133        assert_eq!(distance.get_penalty("title"), Some(0.1));
134        assert_eq!(distance.get_penalty("author"), Some(0.2));
135    }
136
137    #[test]
138    fn test_current_metadata_is_sufficient() {
139        let metadata = CurrentMetadata {
140            title: Some("Test".to_string()),
141            author: None,
142            year: None,
143            duration: None,
144            source: MetadataSource::Embedded,
145        };
146        assert!(metadata.is_sufficient());
147
148        let empty = CurrentMetadata {
149            title: None,
150            author: None,
151            year: None,
152            duration: None,
153            source: MetadataSource::Embedded,
154        };
155        assert!(!empty.is_sufficient());
156    }
157
158    #[test]
159    fn test_current_metadata_merge() {
160        let embedded = CurrentMetadata {
161            title: Some("Title from tags".to_string()),
162            author: None,
163            year: Some(2020),
164            duration: None,
165            source: MetadataSource::Embedded,
166        };
167
168        let filename = CurrentMetadata {
169            title: Some("Title from filename".to_string()),
170            author: Some("Author from filename".to_string()),
171            year: None,
172            duration: None,
173            source: MetadataSource::Filename,
174        };
175
176        let merged = embedded.merge_with(filename);
177
178        // Embedded takes priority for title
179        assert_eq!(merged.title, Some("Title from tags".to_string()));
180        // Filename provides author
181        assert_eq!(merged.author, Some("Author from filename".to_string()));
182        // Year from embedded
183        assert_eq!(merged.year, Some(2020));
184        // Source from embedded (first)
185        assert_eq!(merged.source, MetadataSource::Embedded);
186    }
187}