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