feedparser_rs/types/
podcast.rs

1/// iTunes podcast metadata for feeds
2///
3/// Contains podcast-level iTunes namespace metadata from the `itunes:` prefix.
4/// Namespace URI: `http://www.itunes.com/dtds/podcast-1.0.dtd`
5///
6/// # Examples
7///
8/// ```
9/// use feedparser_rs::ItunesFeedMeta;
10///
11/// let mut itunes = ItunesFeedMeta::default();
12/// itunes.author = Some("John Doe".to_string());
13/// itunes.explicit = Some(false);
14/// itunes.podcast_type = Some("episodic".to_string());
15///
16/// assert_eq!(itunes.author.as_deref(), Some("John Doe"));
17/// ```
18#[derive(Debug, Clone, Default)]
19pub struct ItunesFeedMeta {
20    /// Podcast author (itunes:author)
21    pub author: Option<String>,
22    /// Podcast owner contact information (itunes:owner)
23    pub owner: Option<ItunesOwner>,
24    /// Podcast categories with optional subcategories
25    pub categories: Vec<ItunesCategory>,
26    /// Explicit content flag (itunes:explicit)
27    pub explicit: Option<bool>,
28    /// Podcast artwork URL (itunes:image href attribute)
29    pub image: Option<String>,
30    /// Search keywords (itunes:keywords)
31    pub keywords: Vec<String>,
32    /// Podcast type: "episodic" or "serial"
33    pub podcast_type: Option<String>,
34}
35
36/// iTunes podcast metadata for episodes
37///
38/// Contains episode-level iTunes namespace metadata from the `itunes:` prefix.
39///
40/// # Examples
41///
42/// ```
43/// use feedparser_rs::ItunesEntryMeta;
44///
45/// let mut episode = ItunesEntryMeta::default();
46/// episode.duration = Some(3600); // 1 hour
47/// episode.episode = Some(42);
48/// episode.season = Some(3);
49/// episode.episode_type = Some("full".to_string());
50///
51/// assert_eq!(episode.duration, Some(3600));
52/// ```
53#[derive(Debug, Clone, Default)]
54pub struct ItunesEntryMeta {
55    /// Episode title override (itunes:title)
56    pub title: Option<String>,
57    /// Episode author (itunes:author)
58    pub author: Option<String>,
59    /// Episode duration in seconds
60    ///
61    /// Parsed from various formats: "3600", "60:00", "1:00:00"
62    pub duration: Option<u32>,
63    /// Explicit content flag for this episode
64    pub explicit: Option<bool>,
65    /// Episode-specific artwork URL (itunes:image href)
66    pub image: Option<String>,
67    /// Episode number (itunes:episode)
68    pub episode: Option<u32>,
69    /// Season number (itunes:season)
70    pub season: Option<u32>,
71    /// Episode type: "full", "trailer", or "bonus"
72    pub episode_type: Option<String>,
73}
74
75/// iTunes podcast owner information
76///
77/// Contact information for the podcast owner (itunes:owner).
78///
79/// # Examples
80///
81/// ```
82/// use feedparser_rs::ItunesOwner;
83///
84/// let owner = ItunesOwner {
85///     name: Some("Jane Doe".to_string()),
86///     email: Some("jane@example.com".to_string()),
87/// };
88///
89/// assert_eq!(owner.name.as_deref(), Some("Jane Doe"));
90/// ```
91#[derive(Debug, Clone, Default)]
92pub struct ItunesOwner {
93    /// Owner's name (itunes:name)
94    pub name: Option<String>,
95    /// Owner's email address (itunes:email)
96    pub email: Option<String>,
97}
98
99/// iTunes category with optional subcategory
100///
101/// Categories follow Apple's podcast category taxonomy.
102///
103/// # Examples
104///
105/// ```
106/// use feedparser_rs::ItunesCategory;
107///
108/// let category = ItunesCategory {
109///     text: "Technology".to_string(),
110///     subcategory: Some("Software How-To".to_string()),
111/// };
112///
113/// assert_eq!(category.text, "Technology");
114/// ```
115#[derive(Debug, Clone)]
116pub struct ItunesCategory {
117    /// Category name (text attribute)
118    pub text: String,
119    /// Optional subcategory (nested itunes:category text attribute)
120    pub subcategory: Option<String>,
121}
122
123/// Podcast 2.0 metadata
124///
125/// Modern podcast namespace extensions from `https://podcastindex.org/namespace/1.0`
126///
127/// # Examples
128///
129/// ```
130/// use feedparser_rs::PodcastMeta;
131///
132/// let mut podcast = PodcastMeta::default();
133/// podcast.guid = Some("9b024349-ccf0-5f69-a609-6b82873eab3c".to_string());
134///
135/// assert!(podcast.guid.is_some());
136/// ```
137#[derive(Debug, Clone, Default)]
138pub struct PodcastMeta {
139    /// Transcript URLs (podcast:transcript)
140    pub transcripts: Vec<PodcastTranscript>,
141    /// Funding/donation links (podcast:funding)
142    pub funding: Vec<PodcastFunding>,
143    /// People associated with podcast (podcast:person)
144    pub persons: Vec<PodcastPerson>,
145    /// Permanent podcast GUID (podcast:guid)
146    pub guid: Option<String>,
147}
148
149/// Podcast 2.0 transcript
150///
151/// Links to transcript files in various formats.
152///
153/// # Examples
154///
155/// ```
156/// use feedparser_rs::PodcastTranscript;
157///
158/// let transcript = PodcastTranscript {
159///     url: "https://example.com/transcript.txt".to_string(),
160///     transcript_type: Some("text/plain".to_string()),
161///     language: Some("en".to_string()),
162///     rel: None,
163/// };
164///
165/// assert_eq!(transcript.url, "https://example.com/transcript.txt");
166/// ```
167#[derive(Debug, Clone)]
168pub struct PodcastTranscript {
169    /// Transcript URL (url attribute)
170    pub url: String,
171    /// MIME type (type attribute): "text/plain", "text/html", "application/json", etc.
172    pub transcript_type: Option<String>,
173    /// Language code (language attribute): "en", "es", etc.
174    pub language: Option<String>,
175    /// Relationship (rel attribute): "captions" or empty
176    pub rel: Option<String>,
177}
178
179/// Podcast 2.0 funding information
180///
181/// Links for supporting the podcast financially.
182///
183/// # Examples
184///
185/// ```
186/// use feedparser_rs::PodcastFunding;
187///
188/// let funding = PodcastFunding {
189///     url: "https://example.com/donate".to_string(),
190///     message: Some("Support our show!".to_string()),
191/// };
192///
193/// assert_eq!(funding.url, "https://example.com/donate");
194/// ```
195#[derive(Debug, Clone)]
196pub struct PodcastFunding {
197    /// Funding URL (url attribute)
198    pub url: String,
199    /// Optional message/call-to-action (text content)
200    pub message: Option<String>,
201}
202
203/// Podcast 2.0 person
204///
205/// Information about hosts, guests, or other people associated with the podcast.
206///
207/// # Examples
208///
209/// ```
210/// use feedparser_rs::PodcastPerson;
211///
212/// let host = PodcastPerson {
213///     name: "John Doe".to_string(),
214///     role: Some("host".to_string()),
215///     group: None,
216///     img: Some("https://example.com/john.jpg".to_string()),
217///     href: Some("https://example.com/john".to_string()),
218/// };
219///
220/// assert_eq!(host.name, "John Doe");
221/// assert_eq!(host.role.as_deref(), Some("host"));
222/// ```
223#[derive(Debug, Clone)]
224pub struct PodcastPerson {
225    /// Person's name (text content)
226    pub name: String,
227    /// Role: "host", "guest", "editor", etc. (role attribute)
228    pub role: Option<String>,
229    /// Group name (group attribute)
230    pub group: Option<String>,
231    /// Image URL (img attribute)
232    pub img: Option<String>,
233    /// Personal URL/homepage (href attribute)
234    pub href: Option<String>,
235}
236
237/// Parse duration from various iTunes duration formats
238///
239/// Supports multiple duration formats:
240/// - Seconds only: "3600" → 3600 seconds
241/// - MM:SS format: "60:30" → 3630 seconds
242/// - HH:MM:SS format: "1:00:30" → 3630 seconds
243///
244/// # Arguments
245///
246/// * `s` - Duration string in any supported format
247///
248/// # Examples
249///
250/// ```
251/// use feedparser_rs::parse_duration;
252///
253/// assert_eq!(parse_duration("3600"), Some(3600));
254/// assert_eq!(parse_duration("60:30"), Some(3630));
255/// assert_eq!(parse_duration("1:00:30"), Some(3630));
256/// assert_eq!(parse_duration("1:30"), Some(90));
257/// assert_eq!(parse_duration("invalid"), None);
258/// ```
259pub fn parse_duration(s: &str) -> Option<u32> {
260    let s = s.trim();
261
262    // Try parsing as plain seconds first
263    if let Ok(secs) = s.parse::<u32>() {
264        return Some(secs);
265    }
266
267    // Parse HH:MM:SS or MM:SS format
268    let parts: Vec<&str> = s.split(':').collect();
269    match parts.len() {
270        1 => s.parse().ok(),
271        2 => {
272            // MM:SS
273            let min = parts[0].parse::<u32>().ok()?;
274            let sec = parts[1].parse::<u32>().ok()?;
275            Some(min * 60 + sec)
276        }
277        3 => {
278            // HH:MM:SS
279            let hr = parts[0].parse::<u32>().ok()?;
280            let min = parts[1].parse::<u32>().ok()?;
281            let sec = parts[2].parse::<u32>().ok()?;
282            Some(hr * 3600 + min * 60 + sec)
283        }
284        _ => None,
285    }
286}
287
288/// Parse iTunes explicit flag from various string representations
289///
290/// Accepts multiple boolean representations:
291/// - True values: "yes", "true", "explicit"
292/// - False values: "no", "false", "clean"
293/// - Unknown values return None
294///
295/// Case-insensitive matching.
296///
297/// # Arguments
298///
299/// * `s` - Explicit flag string
300///
301/// # Examples
302///
303/// ```
304/// use feedparser_rs::parse_explicit;
305///
306/// assert_eq!(parse_explicit("yes"), Some(true));
307/// assert_eq!(parse_explicit("YES"), Some(true));
308/// assert_eq!(parse_explicit("true"), Some(true));
309/// assert_eq!(parse_explicit("explicit"), Some(true));
310///
311/// assert_eq!(parse_explicit("no"), Some(false));
312/// assert_eq!(parse_explicit("false"), Some(false));
313/// assert_eq!(parse_explicit("clean"), Some(false));
314///
315/// assert_eq!(parse_explicit("unknown"), None);
316/// ```
317pub fn parse_explicit(s: &str) -> Option<bool> {
318    match s.trim().to_lowercase().as_str() {
319        "yes" | "true" | "explicit" => Some(true),
320        "no" | "false" | "clean" => Some(false),
321        _ => None,
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    #[test]
330    fn test_parse_duration_seconds() {
331        assert_eq!(parse_duration("3600"), Some(3600));
332        assert_eq!(parse_duration("0"), Some(0));
333        assert_eq!(parse_duration("7200"), Some(7200));
334    }
335
336    #[test]
337    fn test_parse_duration_mmss() {
338        assert_eq!(parse_duration("60:30"), Some(3630));
339        assert_eq!(parse_duration("1:30"), Some(90));
340        assert_eq!(parse_duration("0:45"), Some(45));
341        assert_eq!(parse_duration("120:00"), Some(7200));
342    }
343
344    #[test]
345    fn test_parse_duration_hhmmss() {
346        assert_eq!(parse_duration("1:00:30"), Some(3630));
347        assert_eq!(parse_duration("2:30:45"), Some(9045));
348        assert_eq!(parse_duration("0:01:30"), Some(90));
349        assert_eq!(parse_duration("10:00:00"), Some(36000));
350    }
351
352    #[test]
353    fn test_parse_duration_whitespace() {
354        assert_eq!(parse_duration("  3600  "), Some(3600));
355        assert_eq!(parse_duration("  1:30:00  "), Some(5400));
356    }
357
358    #[test]
359    fn test_parse_duration_invalid() {
360        assert_eq!(parse_duration("invalid"), None);
361        assert_eq!(parse_duration("1:2:3:4"), None);
362        assert_eq!(parse_duration(""), None);
363        assert_eq!(parse_duration("abc:def"), None);
364    }
365
366    #[test]
367    fn test_parse_explicit_true_variants() {
368        assert_eq!(parse_explicit("yes"), Some(true));
369        assert_eq!(parse_explicit("YES"), Some(true));
370        assert_eq!(parse_explicit("Yes"), Some(true));
371        assert_eq!(parse_explicit("true"), Some(true));
372        assert_eq!(parse_explicit("TRUE"), Some(true));
373        assert_eq!(parse_explicit("explicit"), Some(true));
374        assert_eq!(parse_explicit("EXPLICIT"), Some(true));
375    }
376
377    #[test]
378    fn test_parse_explicit_false_variants() {
379        assert_eq!(parse_explicit("no"), Some(false));
380        assert_eq!(parse_explicit("NO"), Some(false));
381        assert_eq!(parse_explicit("No"), Some(false));
382        assert_eq!(parse_explicit("false"), Some(false));
383        assert_eq!(parse_explicit("FALSE"), Some(false));
384        assert_eq!(parse_explicit("clean"), Some(false));
385        assert_eq!(parse_explicit("CLEAN"), Some(false));
386    }
387
388    #[test]
389    fn test_parse_explicit_whitespace() {
390        assert_eq!(parse_explicit("  yes  "), Some(true));
391        assert_eq!(parse_explicit("  no  "), Some(false));
392    }
393
394    #[test]
395    fn test_parse_explicit_unknown() {
396        assert_eq!(parse_explicit("unknown"), None);
397        assert_eq!(parse_explicit("maybe"), None);
398        assert_eq!(parse_explicit(""), None);
399        assert_eq!(parse_explicit("1"), None);
400    }
401
402    #[test]
403    fn test_itunes_feed_meta_default() {
404        let meta = ItunesFeedMeta::default();
405        assert!(meta.author.is_none());
406        assert!(meta.owner.is_none());
407        assert!(meta.categories.is_empty());
408        assert!(meta.explicit.is_none());
409        assert!(meta.image.is_none());
410        assert!(meta.keywords.is_empty());
411        assert!(meta.podcast_type.is_none());
412    }
413
414    #[test]
415    fn test_itunes_entry_meta_default() {
416        let meta = ItunesEntryMeta::default();
417        assert!(meta.title.is_none());
418        assert!(meta.author.is_none());
419        assert!(meta.duration.is_none());
420        assert!(meta.explicit.is_none());
421        assert!(meta.image.is_none());
422        assert!(meta.episode.is_none());
423        assert!(meta.season.is_none());
424        assert!(meta.episode_type.is_none());
425    }
426
427    #[test]
428    fn test_itunes_owner_default() {
429        let owner = ItunesOwner::default();
430        assert!(owner.name.is_none());
431        assert!(owner.email.is_none());
432    }
433
434    #[test]
435    #[allow(clippy::redundant_clone)]
436    fn test_itunes_category_clone() {
437        let category = ItunesCategory {
438            text: "Technology".to_string(),
439            subcategory: Some("Software".to_string()),
440        };
441        let cloned = category.clone();
442        assert_eq!(cloned.text, "Technology");
443        assert_eq!(cloned.subcategory.as_deref(), Some("Software"));
444    }
445
446    #[test]
447    fn test_podcast_meta_default() {
448        let meta = PodcastMeta::default();
449        assert!(meta.transcripts.is_empty());
450        assert!(meta.funding.is_empty());
451        assert!(meta.persons.is_empty());
452        assert!(meta.guid.is_none());
453    }
454
455    #[test]
456    #[allow(clippy::redundant_clone)]
457    fn test_podcast_transcript_clone() {
458        let transcript = PodcastTranscript {
459            url: "https://example.com/transcript.txt".to_string(),
460            transcript_type: Some("text/plain".to_string()),
461            language: Some("en".to_string()),
462            rel: None,
463        };
464        let cloned = transcript.clone();
465        assert_eq!(cloned.url, "https://example.com/transcript.txt");
466        assert_eq!(cloned.transcript_type.as_deref(), Some("text/plain"));
467    }
468
469    #[test]
470    #[allow(clippy::redundant_clone)]
471    fn test_podcast_funding_clone() {
472        let funding = PodcastFunding {
473            url: "https://example.com/donate".to_string(),
474            message: Some("Support us!".to_string()),
475        };
476        let cloned = funding.clone();
477        assert_eq!(cloned.url, "https://example.com/donate");
478        assert_eq!(cloned.message.as_deref(), Some("Support us!"));
479    }
480
481    #[test]
482    #[allow(clippy::redundant_clone)]
483    fn test_podcast_person_clone() {
484        let person = PodcastPerson {
485            name: "John Doe".to_string(),
486            role: Some("host".to_string()),
487            group: None,
488            img: Some("https://example.com/john.jpg".to_string()),
489            href: Some("https://example.com".to_string()),
490        };
491        let cloned = person.clone();
492        assert_eq!(cloned.name, "John Doe");
493        assert_eq!(cloned.role.as_deref(), Some("host"));
494    }
495}