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}