audiobook_forge/models/
audible.rs

1//! Audible metadata models and types
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5use std::str::FromStr;
6use anyhow::{bail, Result};
7
8/// Audible region with TLD mapping
9#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
10pub enum AudibleRegion {
11    #[serde(rename = "us")]
12    US,
13    #[serde(rename = "ca")]
14    CA,
15    #[serde(rename = "uk")]
16    UK,
17    #[serde(rename = "au")]
18    AU,
19    #[serde(rename = "fr")]
20    FR,
21    #[serde(rename = "de")]
22    DE,
23    #[serde(rename = "jp")]
24    JP,
25    #[serde(rename = "it")]
26    IT,
27    #[serde(rename = "in")]
28    IN,
29    #[serde(rename = "es")]
30    ES,
31}
32
33impl AudibleRegion {
34    /// Get the TLD for this region
35    pub fn tld(&self) -> &'static str {
36        match self {
37            Self::US => "us",
38            Self::CA => "ca",
39            Self::UK => "uk",
40            Self::AU => "au",
41            Self::FR => "fr",
42            Self::DE => "de",
43            Self::JP => "jp",
44            Self::IT => "it",
45            Self::IN => "in",
46            Self::ES => "es",
47        }
48    }
49}
50
51impl FromStr for AudibleRegion {
52    type Err = anyhow::Error;
53
54    fn from_str(s: &str) -> Result<Self> {
55        match s.to_lowercase().as_str() {
56            "us" => Ok(Self::US),
57            "ca" => Ok(Self::CA),
58            "uk" => Ok(Self::UK),
59            "au" => Ok(Self::AU),
60            "fr" => Ok(Self::FR),
61            "de" => Ok(Self::DE),
62            "jp" => Ok(Self::JP),
63            "it" => Ok(Self::IT),
64            "in" => Ok(Self::IN),
65            "es" => Ok(Self::ES),
66            _ => bail!("Invalid Audible region: {}. Valid regions: us, ca, uk, au, fr, de, jp, it, in, es", s),
67        }
68    }
69}
70
71impl fmt::Display for AudibleRegion {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        write!(f, "{}", self.tld())
74    }
75}
76
77impl Default for AudibleRegion {
78    fn default() -> Self {
79        Self::US
80    }
81}
82
83/// Audible metadata from API
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct AudibleMetadata {
86    pub asin: String,
87    pub title: String,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub subtitle: Option<String>,
90    #[serde(default)]
91    pub authors: Vec<AudibleAuthor>,
92    #[serde(default)]
93    pub narrators: Vec<String>,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub publisher: Option<String>,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub published_year: Option<u32>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub description: Option<String>,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub cover_url: Option<String>,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub isbn: Option<String>,
104    #[serde(default)]
105    pub genres: Vec<String>,
106    #[serde(default)]
107    pub tags: Vec<String>,
108    #[serde(default)]
109    pub series: Vec<AudibleSeries>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub language: Option<String>,
112    /// Runtime length in milliseconds
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub runtime_length_ms: Option<u64>,
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub rating: Option<f32>,
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub is_abridged: Option<bool>,
119}
120
121impl AudibleMetadata {
122    /// Get runtime in minutes
123    pub fn runtime_minutes(&self) -> Option<u32> {
124        self.runtime_length_ms.map(|ms| (ms / 60_000) as u32)
125    }
126
127    /// Get primary author name
128    pub fn primary_author(&self) -> Option<&str> {
129        self.authors.first().map(|a| a.name.as_str())
130    }
131
132    /// Get all authors joined as a string
133    pub fn authors_string(&self) -> String {
134        self.authors
135            .iter()
136            .map(|a| a.name.as_str())
137            .collect::<Vec<_>>()
138            .join(", ")
139    }
140
141    /// Get all narrators joined as a string
142    pub fn narrators_string(&self) -> String {
143        self.narrators.join(", ")
144    }
145
146    /// Get primary narrator
147    pub fn primary_narrator(&self) -> Option<&str> {
148        self.narrators.first().map(|n| n.as_str())
149    }
150}
151
152/// Audible author information
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct AudibleAuthor {
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub asin: Option<String>,
157    pub name: String,
158}
159
160/// Audible series information
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct AudibleSeries {
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub asin: Option<String>,
165    pub name: String,
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub sequence: Option<String>,
168}
169
170/// Search result from Audible catalog
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct AudibleSearchResult {
173    pub asin: String,
174    pub title: String,
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub subtitle: Option<String>,
177    #[serde(default)]
178    pub authors: Vec<String>,
179    #[serde(default)]
180    pub narrators: Vec<String>,
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub runtime_ms: Option<u64>,
183}
184
185impl AudibleSearchResult {
186    /// Get runtime in minutes
187    pub fn runtime_minutes(&self) -> Option<u32> {
188        self.runtime_ms.map(|ms| (ms / 60_000) as u32)
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn test_region_from_str() {
198        assert_eq!(AudibleRegion::from_str("us").unwrap(), AudibleRegion::US);
199        assert_eq!(AudibleRegion::from_str("UK").unwrap(), AudibleRegion::UK);
200        assert_eq!(AudibleRegion::from_str("Ca").unwrap(), AudibleRegion::CA);
201        assert!(AudibleRegion::from_str("invalid").is_err());
202    }
203
204    #[test]
205    fn test_region_tld() {
206        assert_eq!(AudibleRegion::US.tld(), "us");
207        assert_eq!(AudibleRegion::UK.tld(), "uk");
208        assert_eq!(AudibleRegion::FR.tld(), "fr");
209    }
210
211    #[test]
212    fn test_region_display() {
213        assert_eq!(format!("{}", AudibleRegion::US), "us");
214        assert_eq!(format!("{}", AudibleRegion::UK), "uk");
215    }
216
217    #[test]
218    fn test_runtime_conversion() {
219        let metadata = AudibleMetadata {
220            asin: "B001".to_string(),
221            title: "Test".to_string(),
222            subtitle: None,
223            authors: vec![],
224            narrators: vec![],
225            publisher: None,
226            published_year: None,
227            description: None,
228            cover_url: None,
229            isbn: None,
230            genres: vec![],
231            tags: vec![],
232            series: vec![],
233            language: None,
234            runtime_length_ms: Some(3_600_000), // 1 hour in ms
235            rating: None,
236            is_abridged: None,
237        };
238
239        assert_eq!(metadata.runtime_minutes(), Some(60));
240    }
241
242    #[test]
243    fn test_authors_string() {
244        let metadata = AudibleMetadata {
245            asin: "B001".to_string(),
246            title: "Test".to_string(),
247            subtitle: None,
248            authors: vec![
249                AudibleAuthor {
250                    asin: None,
251                    name: "Author One".to_string(),
252                },
253                AudibleAuthor {
254                    asin: None,
255                    name: "Author Two".to_string(),
256                },
257            ],
258            narrators: vec!["Narrator One".to_string(), "Narrator Two".to_string()],
259            publisher: None,
260            published_year: None,
261            description: None,
262            cover_url: None,
263            isbn: None,
264            genres: vec![],
265            tags: vec![],
266            series: vec![],
267            language: None,
268            runtime_length_ms: None,
269            rating: None,
270            is_abridged: None,
271        };
272
273        assert_eq!(metadata.authors_string(), "Author One, Author Two");
274        assert_eq!(metadata.narrators_string(), "Narrator One, Narrator Two");
275        assert_eq!(metadata.primary_author(), Some("Author One"));
276        assert_eq!(metadata.primary_narrator(), Some("Narrator One"));
277    }
278}