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/// Chapter information from Audnex API
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct AudibleChapter {
211    /// Chapter title
212    pub title: String,
213    /// Duration in milliseconds
214    #[serde(rename = "lengthMs")]
215    pub length_ms: u64,
216    /// Start offset in milliseconds from beginning
217    #[serde(rename = "startOffsetMs")]
218    pub start_offset_ms: u64,
219    /// Start offset in seconds (convenience field)
220    #[serde(rename = "startOffsetSec", default)]
221    pub start_offset_sec: Option<u32>,
222}
223
224impl AudibleChapter {
225    /// Get end time in milliseconds
226    pub fn end_offset_ms(&self) -> u64 {
227        self.start_offset_ms + self.length_ms
228    }
229
230    /// Convert to internal Chapter struct
231    pub fn to_chapter(&self, number: u32) -> crate::audio::Chapter {
232        crate::audio::Chapter::new(
233            number,
234            self.title.clone(),
235            self.start_offset_ms,
236            self.end_offset_ms(),
237        )
238    }
239}
240
241/// Audnex chapters API response
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct AudnexChaptersResponse {
244    pub asin: String,
245    #[serde(rename = "brandIntroDurationMs", default)]
246    pub brand_intro_duration_ms: Option<u64>,
247    #[serde(rename = "brandOutroDurationMs", default)]
248    pub brand_outro_duration_ms: Option<u64>,
249    pub chapters: Vec<AudibleChapter>,
250    #[serde(rename = "isAccurate", default)]
251    pub is_accurate: Option<bool>,
252    #[serde(default)]
253    pub region: Option<String>,
254    #[serde(rename = "runtimeLengthMs", default)]
255    pub runtime_length_ms: Option<u64>,
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn test_region_from_str() {
264        assert_eq!(AudibleRegion::from_str("us").unwrap(), AudibleRegion::US);
265        assert_eq!(AudibleRegion::from_str("UK").unwrap(), AudibleRegion::UK);
266        assert_eq!(AudibleRegion::from_str("Ca").unwrap(), AudibleRegion::CA);
267        assert!(AudibleRegion::from_str("invalid").is_err());
268    }
269
270    #[test]
271    fn test_region_tld() {
272        assert_eq!(AudibleRegion::US.tld(), "us");
273        assert_eq!(AudibleRegion::UK.tld(), "uk");
274        assert_eq!(AudibleRegion::FR.tld(), "fr");
275    }
276
277    #[test]
278    fn test_region_display() {
279        assert_eq!(format!("{}", AudibleRegion::US), "us");
280        assert_eq!(format!("{}", AudibleRegion::UK), "uk");
281    }
282
283    #[test]
284    fn test_runtime_conversion() {
285        let metadata = AudibleMetadata {
286            asin: "B001".to_string(),
287            title: "Test".to_string(),
288            subtitle: None,
289            authors: vec![],
290            narrators: vec![],
291            publisher: None,
292            published_year: None,
293            description: None,
294            cover_url: None,
295            isbn: None,
296            genres: vec![],
297            tags: vec![],
298            series: vec![],
299            language: None,
300            runtime_length_ms: Some(3_600_000), // 1 hour in ms
301            rating: None,
302            is_abridged: None,
303        };
304
305        assert_eq!(metadata.runtime_minutes(), Some(60));
306    }
307
308    #[test]
309    fn test_authors_string() {
310        let metadata = AudibleMetadata {
311            asin: "B001".to_string(),
312            title: "Test".to_string(),
313            subtitle: None,
314            authors: vec![
315                AudibleAuthor {
316                    asin: None,
317                    name: "Author One".to_string(),
318                },
319                AudibleAuthor {
320                    asin: None,
321                    name: "Author Two".to_string(),
322                },
323            ],
324            narrators: vec!["Narrator One".to_string(), "Narrator Two".to_string()],
325            publisher: None,
326            published_year: None,
327            description: None,
328            cover_url: None,
329            isbn: None,
330            genres: vec![],
331            tags: vec![],
332            series: vec![],
333            language: None,
334            runtime_length_ms: None,
335            rating: None,
336            is_abridged: None,
337        };
338
339        assert_eq!(metadata.authors_string(), "Author One, Author Two");
340        assert_eq!(metadata.narrators_string(), "Narrator One, Narrator Two");
341        assert_eq!(metadata.primary_author(), Some("Author One"));
342        assert_eq!(metadata.primary_narrator(), Some("Narrator One"));
343    }
344
345    #[test]
346    fn test_audible_chapter_end_offset() {
347        let chapter = AudibleChapter {
348            title: "Prologue".to_string(),
349            length_ms: 300_000, // 5 minutes
350            start_offset_ms: 0,
351            start_offset_sec: Some(0),
352        };
353
354        assert_eq!(chapter.end_offset_ms(), 300_000);
355    }
356
357    #[test]
358    fn test_audible_chapter_to_chapter_conversion() {
359        let audible_chapter = AudibleChapter {
360            title: "Chapter 1".to_string(),
361            length_ms: 600_000, // 10 minutes
362            start_offset_ms: 300_000, // starts at 5 min
363            start_offset_sec: Some(300),
364        };
365
366        let chapter = audible_chapter.to_chapter(1);
367
368        assert_eq!(chapter.number, 1);
369        assert_eq!(chapter.title, "Chapter 1");
370        assert_eq!(chapter.start_time_ms, 300_000);
371        assert_eq!(chapter.end_time_ms, 900_000);
372    }
373}