feedparser_rs/types/podcast.rs
1use super::common::{MimeType, Url};
2
3/// iTunes podcast metadata for feeds
4///
5/// Contains podcast-level iTunes namespace metadata from the `itunes:` prefix.
6/// Namespace URI: `http://www.itunes.com/dtds/podcast-1.0.dtd`
7///
8/// # Examples
9///
10/// ```
11/// use feedparser_rs::ItunesFeedMeta;
12///
13/// let mut itunes = ItunesFeedMeta::default();
14/// itunes.author = Some("John Doe".to_string());
15/// itunes.explicit = Some(false);
16/// itunes.podcast_type = Some("episodic".to_string());
17///
18/// assert_eq!(itunes.author.as_deref(), Some("John Doe"));
19/// ```
20#[derive(Debug, Clone, Default)]
21pub struct ItunesFeedMeta {
22 /// Podcast author (itunes:author)
23 pub author: Option<String>,
24 /// Podcast owner contact information (itunes:owner)
25 pub owner: Option<ItunesOwner>,
26 /// Podcast categories with optional subcategories
27 pub categories: Vec<ItunesCategory>,
28 /// Explicit content flag (itunes:explicit)
29 pub explicit: Option<bool>,
30 /// Podcast artwork URL (itunes:image href attribute)
31 pub image: Option<Url>,
32 /// Search keywords (itunes:keywords)
33 pub keywords: Vec<String>,
34 /// Podcast type: "episodic" or "serial"
35 pub podcast_type: Option<String>,
36 /// Podcast completion status (itunes:complete)
37 ///
38 /// Set to true if podcast is complete and no new episodes will be published.
39 /// Value is "Yes" in the feed for true.
40 pub complete: Option<bool>,
41 /// New feed URL for migrated podcasts (itunes:new-feed-url)
42 ///
43 /// Indicates the podcast has moved to a new feed location.
44 ///
45 /// # Security Warning
46 ///
47 /// This URL comes from untrusted feed input and has NOT been validated for SSRF.
48 /// Applications MUST validate URLs before fetching to prevent SSRF attacks.
49 pub new_feed_url: Option<Url>,
50 /// Podcast subtitle (itunes:subtitle)
51 pub subtitle: Option<String>,
52 /// Podcast summary (itunes:summary)
53 pub summary: Option<String>,
54}
55
56/// iTunes podcast metadata for episodes
57///
58/// Contains episode-level iTunes namespace metadata from the `itunes:` prefix.
59///
60/// # Examples
61///
62/// ```
63/// use feedparser_rs::ItunesEntryMeta;
64///
65/// let mut episode = ItunesEntryMeta::default();
66/// episode.duration = Some(3600); // 1 hour
67/// episode.episode = Some(42);
68/// episode.season = Some(3);
69/// episode.episode_type = Some("full".to_string());
70///
71/// assert_eq!(episode.duration, Some(3600));
72/// ```
73#[derive(Debug, Clone, Default)]
74pub struct ItunesEntryMeta {
75 /// Episode title override (itunes:title)
76 pub title: Option<String>,
77 /// Episode author (itunes:author)
78 pub author: Option<String>,
79 /// Episode duration in seconds
80 ///
81 /// Parsed from various formats: "3600", "60:00", "1:00:00"
82 pub duration: Option<u32>,
83 /// Explicit content flag for this episode
84 pub explicit: Option<bool>,
85 /// Episode-specific artwork URL (itunes:image href)
86 pub image: Option<Url>,
87 /// Episode number (itunes:episode)
88 pub episode: Option<u32>,
89 /// Season number (itunes:season)
90 pub season: Option<u32>,
91 /// Episode type: "full", "trailer", or "bonus"
92 pub episode_type: Option<String>,
93 /// Episode subtitle (itunes:subtitle)
94 pub subtitle: Option<String>,
95 /// Episode summary (itunes:summary)
96 pub summary: Option<String>,
97}
98
99/// iTunes podcast owner information
100///
101/// Contact information for the podcast owner (itunes:owner).
102///
103/// # Examples
104///
105/// ```
106/// use feedparser_rs::ItunesOwner;
107///
108/// let owner = ItunesOwner {
109/// name: Some("Jane Doe".to_string()),
110/// email: Some("jane@example.com".to_string()),
111/// };
112///
113/// assert_eq!(owner.name.as_deref(), Some("Jane Doe"));
114/// ```
115#[derive(Debug, Clone, Default)]
116pub struct ItunesOwner {
117 /// Owner's name (itunes:name)
118 pub name: Option<String>,
119 /// Owner's email address (itunes:email)
120 pub email: Option<String>,
121}
122
123/// iTunes category with optional subcategory
124///
125/// Categories follow Apple's podcast category taxonomy.
126///
127/// # Examples
128///
129/// ```
130/// use feedparser_rs::ItunesCategory;
131///
132/// let category = ItunesCategory {
133/// text: "Technology".to_string(),
134/// subcategory: Some("Software How-To".to_string()),
135/// };
136///
137/// assert_eq!(category.text, "Technology");
138/// ```
139#[derive(Debug, Clone)]
140pub struct ItunesCategory {
141 /// Category name (text attribute)
142 pub text: String,
143 /// Optional subcategory (nested itunes:category text attribute)
144 pub subcategory: Option<String>,
145}
146
147/// Podcast 2.0 metadata
148///
149/// Modern podcast namespace extensions from `https://podcastindex.org/namespace/1.0`
150///
151/// # Examples
152///
153/// ```
154/// use feedparser_rs::PodcastMeta;
155///
156/// let mut podcast = PodcastMeta::default();
157/// podcast.guid = Some("9b024349-ccf0-5f69-a609-6b82873eab3c".to_string());
158///
159/// assert!(podcast.guid.is_some());
160/// ```
161#[derive(Debug, Clone, Default)]
162pub struct PodcastMeta {
163 /// Transcript URLs (podcast:transcript)
164 pub transcripts: Vec<PodcastTranscript>,
165 /// Funding/donation links (podcast:funding)
166 pub funding: Vec<PodcastFunding>,
167 /// People associated with podcast (podcast:person)
168 pub persons: Vec<PodcastPerson>,
169 /// Permanent podcast GUID (podcast:guid)
170 pub guid: Option<String>,
171 /// Value-for-value payment information (podcast:value)
172 pub value: Option<PodcastValue>,
173}
174
175/// Podcast 2.0 value element for monetization
176///
177/// Implements value-for-value payment model using cryptocurrency and streaming payments.
178/// Used for podcast monetization via Lightning Network, Hive, and other payment methods.
179///
180/// Namespace: `https://podcastindex.org/namespace/1.0`
181///
182/// # Examples
183///
184/// ```
185/// use feedparser_rs::{PodcastValue, PodcastValueRecipient};
186///
187/// let value = PodcastValue {
188/// type_: "lightning".to_string(),
189/// method: "keysend".to_string(),
190/// suggested: Some("0.00000005000".to_string()),
191/// recipients: vec![
192/// PodcastValueRecipient {
193/// name: Some("Host".to_string()),
194/// type_: "node".to_string(),
195/// address: "03ae9f91a0cb8ff43840e3c322c4c61f019d8c1c3cea15a25cfc425ac605e61a4a".to_string(),
196/// split: 90,
197/// fee: Some(false),
198/// },
199/// PodcastValueRecipient {
200/// name: Some("Producer".to_string()),
201/// type_: "node".to_string(),
202/// address: "02d5c1bf8b940dc9cadca86d1b0a3c37fbe39cee4c7e839e33bef9174531d27f52".to_string(),
203/// split: 10,
204/// fee: Some(false),
205/// },
206/// ],
207/// };
208///
209/// assert_eq!(value.type_, "lightning");
210/// assert_eq!(value.recipients.len(), 2);
211/// ```
212#[derive(Debug, Clone, Default, PartialEq, Eq)]
213pub struct PodcastValue {
214 /// Payment type (type attribute): "lightning", "hive", etc.
215 pub type_: String,
216 /// Payment method (method attribute): "keysend" for Lightning Network
217 pub method: String,
218 /// Suggested payment amount (suggested attribute)
219 ///
220 /// Format depends on payment type. For Lightning, this is typically satoshis.
221 pub suggested: Option<String>,
222 /// List of payment recipients with split percentages
223 pub recipients: Vec<PodcastValueRecipient>,
224}
225
226/// Value recipient for payment splitting
227///
228/// Defines a single recipient in the value-for-value payment model.
229/// Each recipient receives a percentage (split) of the total payment.
230///
231/// # Examples
232///
233/// ```
234/// use feedparser_rs::PodcastValueRecipient;
235///
236/// let recipient = PodcastValueRecipient {
237/// name: Some("Podcast Host".to_string()),
238/// type_: "node".to_string(),
239/// address: "03ae9f91a0cb8ff43840e3c322c4c61f019d8c1c3cea15a25cfc425ac605e61a4a".to_string(),
240/// split: 95,
241/// fee: Some(false),
242/// };
243///
244/// assert_eq!(recipient.split, 95);
245/// assert_eq!(recipient.fee, Some(false));
246/// ```
247#[derive(Debug, Clone, Default, PartialEq, Eq)]
248pub struct PodcastValueRecipient {
249 /// Recipient's name (name attribute)
250 pub name: Option<String>,
251 /// Recipient type (type attribute): "node" for Lightning Network nodes
252 pub type_: String,
253 /// Payment address (address attribute)
254 ///
255 /// For Lightning: node public key (hex-encoded)
256 /// For other types: appropriate address format
257 ///
258 /// # Security Warning
259 ///
260 /// This address comes from untrusted feed input. Applications MUST validate
261 /// addresses before sending payments to prevent sending funds to wrong recipients.
262 pub address: String,
263 /// Payment split percentage (split attribute)
264 ///
265 /// Can be absolute percentage (1-100) or relative value that's normalized.
266 /// Total of all splits should equal 100 for percentage-based splits.
267 pub split: u32,
268 /// Whether this is a fee recipient (fee attribute)
269 ///
270 /// Fee recipients are paid before regular splits are calculated.
271 pub fee: Option<bool>,
272}
273
274/// Podcast 2.0 transcript
275///
276/// Links to transcript files in various formats.
277///
278/// # Examples
279///
280/// ```
281/// use feedparser_rs::PodcastTranscript;
282///
283/// let transcript = PodcastTranscript {
284/// url: "https://example.com/transcript.txt".into(),
285/// transcript_type: Some("text/plain".into()),
286/// language: Some("en".to_string()),
287/// rel: None,
288/// };
289///
290/// assert_eq!(transcript.url, "https://example.com/transcript.txt");
291/// ```
292#[derive(Debug, Clone, PartialEq, Eq)]
293pub struct PodcastTranscript {
294 /// Transcript URL (url attribute)
295 ///
296 /// # Security Warning
297 ///
298 /// This URL comes from untrusted feed input and has NOT been validated for SSRF.
299 /// Applications MUST validate URLs before fetching to prevent SSRF attacks.
300 pub url: Url,
301 /// MIME type (type attribute): "text/plain", "text/html", "application/json", etc.
302 pub transcript_type: Option<MimeType>,
303 /// Language code (language attribute): "en", "es", etc.
304 pub language: Option<String>,
305 /// Relationship (rel attribute): "captions" or empty
306 pub rel: Option<String>,
307}
308
309/// Podcast 2.0 funding information
310///
311/// Links for supporting the podcast financially.
312///
313/// # Examples
314///
315/// ```
316/// use feedparser_rs::PodcastFunding;
317///
318/// let funding = PodcastFunding {
319/// url: "https://example.com/donate".into(),
320/// message: Some("Support our show!".to_string()),
321/// };
322///
323/// assert_eq!(funding.url, "https://example.com/donate");
324/// ```
325#[derive(Debug, Clone)]
326pub struct PodcastFunding {
327 /// Funding URL (url attribute)
328 ///
329 /// # Security Warning
330 ///
331 /// This URL comes from untrusted feed input and has NOT been validated for SSRF.
332 /// Applications MUST validate URLs before fetching to prevent SSRF attacks.
333 pub url: Url,
334 /// Optional message/call-to-action (text content)
335 pub message: Option<String>,
336}
337
338/// Podcast 2.0 person
339///
340/// Information about hosts, guests, or other people associated with the podcast.
341///
342/// # Examples
343///
344/// ```
345/// use feedparser_rs::PodcastPerson;
346///
347/// let host = PodcastPerson {
348/// name: "John Doe".to_string(),
349/// role: Some("host".to_string()),
350/// group: None,
351/// img: Some("https://example.com/john.jpg".into()),
352/// href: Some("https://example.com/john".into()),
353/// };
354///
355/// assert_eq!(host.name, "John Doe");
356/// assert_eq!(host.role.as_deref(), Some("host"));
357/// ```
358#[derive(Debug, Clone, PartialEq, Eq)]
359pub struct PodcastPerson {
360 /// Person's name (text content)
361 pub name: String,
362 /// Role: "host", "guest", "editor", etc. (role attribute)
363 pub role: Option<String>,
364 /// Group name (group attribute)
365 pub group: Option<String>,
366 /// Image URL (img attribute)
367 ///
368 /// # Security Warning
369 ///
370 /// This URL comes from untrusted feed input and has NOT been validated for SSRF.
371 /// Applications MUST validate URLs before fetching to prevent SSRF attacks.
372 pub img: Option<Url>,
373 /// Personal URL/homepage (href attribute)
374 ///
375 /// # Security Warning
376 ///
377 /// This URL comes from untrusted feed input and has NOT been validated for SSRF.
378 /// Applications MUST validate URLs before fetching to prevent SSRF attacks.
379 pub href: Option<Url>,
380}
381
382/// Podcast 2.0 chapters information
383///
384/// Links to chapter markers for time-based navigation within an episode.
385/// Namespace: `https://podcastindex.org/namespace/1.0`
386///
387/// # Examples
388///
389/// ```
390/// use feedparser_rs::PodcastChapters;
391///
392/// let chapters = PodcastChapters {
393/// url: "https://example.com/chapters.json".into(),
394/// type_: "application/json+chapters".into(),
395/// };
396///
397/// assert_eq!(chapters.url, "https://example.com/chapters.json");
398/// ```
399#[derive(Debug, Clone, Default, PartialEq, Eq)]
400pub struct PodcastChapters {
401 /// Chapters file URL (url attribute)
402 ///
403 /// # Security Warning
404 ///
405 /// This URL comes from untrusted feed input and has NOT been validated for SSRF.
406 /// Applications MUST validate URLs before fetching to prevent SSRF attacks.
407 pub url: Url,
408 /// MIME type (type attribute): "application/json+chapters" or "application/xml+chapters"
409 pub type_: MimeType,
410}
411
412/// Podcast 2.0 soundbite (shareable clip)
413///
414/// Marks a portion of the audio for social sharing or highlights.
415/// Namespace: `https://podcastindex.org/namespace/1.0`
416///
417/// # Examples
418///
419/// ```
420/// use feedparser_rs::PodcastSoundbite;
421///
422/// let soundbite = PodcastSoundbite {
423/// start_time: 120.5,
424/// duration: 30.0,
425/// title: Some("Great quote".to_string()),
426/// };
427///
428/// assert_eq!(soundbite.start_time, 120.5);
429/// assert_eq!(soundbite.duration, 30.0);
430/// ```
431#[derive(Debug, Clone, Default, PartialEq)]
432#[allow(clippy::derive_partial_eq_without_eq)]
433pub struct PodcastSoundbite {
434 /// Start time in seconds (startTime attribute)
435 pub start_time: f64,
436 /// Duration in seconds (duration attribute)
437 pub duration: f64,
438 /// Optional title/description (text content)
439 pub title: Option<String>,
440}
441
442/// Podcast 2.0 metadata for episodes
443///
444/// Container for entry-level podcast metadata.
445///
446/// # Examples
447///
448/// ```
449/// use feedparser_rs::PodcastEntryMeta;
450///
451/// let mut podcast = PodcastEntryMeta::default();
452/// assert!(podcast.transcript.is_empty());
453/// assert!(podcast.chapters.is_none());
454/// assert!(podcast.soundbite.is_empty());
455/// ```
456#[derive(Debug, Clone, Default, PartialEq)]
457pub struct PodcastEntryMeta {
458 /// Transcript URLs (podcast:transcript)
459 pub transcript: Vec<PodcastTranscript>,
460 /// Chapter markers (podcast:chapters)
461 pub chapters: Option<PodcastChapters>,
462 /// Shareable soundbites (podcast:soundbite)
463 pub soundbite: Vec<PodcastSoundbite>,
464 /// People associated with this episode (podcast:person)
465 pub person: Vec<PodcastPerson>,
466}
467
468/// Parse duration from various iTunes duration formats
469///
470/// Supports multiple duration formats:
471/// - Seconds only: "3600" → 3600 seconds
472/// - MM:SS format: "60:30" → 3630 seconds
473/// - HH:MM:SS format: "1:00:30" → 3630 seconds
474///
475/// # Arguments
476///
477/// * `s` - Duration string in any supported format
478///
479/// # Examples
480///
481/// ```
482/// use feedparser_rs::parse_duration;
483///
484/// assert_eq!(parse_duration("3600"), Some(3600));
485/// assert_eq!(parse_duration("60:30"), Some(3630));
486/// assert_eq!(parse_duration("1:00:30"), Some(3630));
487/// assert_eq!(parse_duration("1:30"), Some(90));
488/// assert_eq!(parse_duration("invalid"), None);
489/// ```
490pub fn parse_duration(s: &str) -> Option<u32> {
491 let s = s.trim();
492
493 // Try parsing as plain seconds first
494 if let Ok(secs) = s.parse::<u32>() {
495 return Some(secs);
496 }
497
498 // Parse HH:MM:SS or MM:SS format using iterator pattern matching
499 let mut parts = s.split(':');
500 match (parts.next(), parts.next(), parts.next(), parts.next()) {
501 (Some(first), None, None, None) => first.parse().ok(),
502 (Some(min), Some(sec), None, None) => {
503 // MM:SS
504 let min = min.parse::<u32>().ok()?;
505 let sec = sec.parse::<u32>().ok()?;
506 Some(min * 60 + sec)
507 }
508 (Some(hr), Some(min), Some(sec), None) => {
509 // HH:MM:SS
510 let hr = hr.parse::<u32>().ok()?;
511 let min = min.parse::<u32>().ok()?;
512 let sec = sec.parse::<u32>().ok()?;
513 Some(hr * 3600 + min * 60 + sec)
514 }
515 _ => None,
516 }
517}
518
519/// Parse iTunes explicit flag from various string representations
520///
521/// Accepts multiple boolean representations:
522/// - True values: "yes", "true", "explicit"
523/// - False values: "no", "false", "clean"
524/// - Unknown values return None
525///
526/// Case-insensitive matching.
527///
528/// # Arguments
529///
530/// * `s` - Explicit flag string
531///
532/// # Examples
533///
534/// ```
535/// use feedparser_rs::parse_explicit;
536///
537/// assert_eq!(parse_explicit("yes"), Some(true));
538/// assert_eq!(parse_explicit("YES"), Some(true));
539/// assert_eq!(parse_explicit("true"), Some(true));
540/// assert_eq!(parse_explicit("explicit"), Some(true));
541///
542/// assert_eq!(parse_explicit("no"), Some(false));
543/// assert_eq!(parse_explicit("false"), Some(false));
544/// assert_eq!(parse_explicit("clean"), Some(false));
545///
546/// assert_eq!(parse_explicit("unknown"), None);
547/// ```
548pub fn parse_explicit(s: &str) -> Option<bool> {
549 let s = s.trim();
550 if s.eq_ignore_ascii_case("yes")
551 || s.eq_ignore_ascii_case("true")
552 || s.eq_ignore_ascii_case("explicit")
553 {
554 Some(true)
555 } else if s.eq_ignore_ascii_case("no")
556 || s.eq_ignore_ascii_case("false")
557 || s.eq_ignore_ascii_case("clean")
558 {
559 Some(false)
560 } else {
561 None
562 }
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568
569 #[test]
570 fn test_parse_duration_seconds() {
571 assert_eq!(parse_duration("3600"), Some(3600));
572 assert_eq!(parse_duration("0"), Some(0));
573 assert_eq!(parse_duration("7200"), Some(7200));
574 }
575
576 #[test]
577 fn test_parse_duration_mmss() {
578 assert_eq!(parse_duration("60:30"), Some(3630));
579 assert_eq!(parse_duration("1:30"), Some(90));
580 assert_eq!(parse_duration("0:45"), Some(45));
581 assert_eq!(parse_duration("120:00"), Some(7200));
582 }
583
584 #[test]
585 fn test_parse_duration_hhmmss() {
586 assert_eq!(parse_duration("1:00:30"), Some(3630));
587 assert_eq!(parse_duration("2:30:45"), Some(9045));
588 assert_eq!(parse_duration("0:01:30"), Some(90));
589 assert_eq!(parse_duration("10:00:00"), Some(36000));
590 }
591
592 #[test]
593 fn test_parse_duration_whitespace() {
594 assert_eq!(parse_duration(" 3600 "), Some(3600));
595 assert_eq!(parse_duration(" 1:30:00 "), Some(5400));
596 }
597
598 #[test]
599 fn test_parse_duration_invalid() {
600 assert_eq!(parse_duration("invalid"), None);
601 assert_eq!(parse_duration("1:2:3:4"), None);
602 assert_eq!(parse_duration(""), None);
603 assert_eq!(parse_duration("abc:def"), None);
604 }
605
606 #[test]
607 fn test_parse_explicit_true_variants() {
608 assert_eq!(parse_explicit("yes"), Some(true));
609 assert_eq!(parse_explicit("YES"), Some(true));
610 assert_eq!(parse_explicit("Yes"), Some(true));
611 assert_eq!(parse_explicit("true"), Some(true));
612 assert_eq!(parse_explicit("TRUE"), Some(true));
613 assert_eq!(parse_explicit("explicit"), Some(true));
614 assert_eq!(parse_explicit("EXPLICIT"), Some(true));
615 }
616
617 #[test]
618 fn test_parse_explicit_false_variants() {
619 assert_eq!(parse_explicit("no"), Some(false));
620 assert_eq!(parse_explicit("NO"), Some(false));
621 assert_eq!(parse_explicit("No"), Some(false));
622 assert_eq!(parse_explicit("false"), Some(false));
623 assert_eq!(parse_explicit("FALSE"), Some(false));
624 assert_eq!(parse_explicit("clean"), Some(false));
625 assert_eq!(parse_explicit("CLEAN"), Some(false));
626 }
627
628 #[test]
629 fn test_parse_explicit_whitespace() {
630 assert_eq!(parse_explicit(" yes "), Some(true));
631 assert_eq!(parse_explicit(" no "), Some(false));
632 }
633
634 #[test]
635 fn test_parse_explicit_unknown() {
636 assert_eq!(parse_explicit("unknown"), None);
637 assert_eq!(parse_explicit("maybe"), None);
638 assert_eq!(parse_explicit(""), None);
639 assert_eq!(parse_explicit("1"), None);
640 }
641
642 #[test]
643 fn test_itunes_feed_meta_default() {
644 let meta = ItunesFeedMeta::default();
645 assert!(meta.author.is_none());
646 assert!(meta.owner.is_none());
647 assert!(meta.categories.is_empty());
648 assert!(meta.explicit.is_none());
649 assert!(meta.image.is_none());
650 assert!(meta.keywords.is_empty());
651 assert!(meta.podcast_type.is_none());
652 assert!(meta.complete.is_none());
653 assert!(meta.new_feed_url.is_none());
654 }
655
656 #[test]
657 fn test_itunes_entry_meta_default() {
658 let meta = ItunesEntryMeta::default();
659 assert!(meta.title.is_none());
660 assert!(meta.author.is_none());
661 assert!(meta.duration.is_none());
662 assert!(meta.explicit.is_none());
663 assert!(meta.image.is_none());
664 assert!(meta.episode.is_none());
665 assert!(meta.season.is_none());
666 assert!(meta.episode_type.is_none());
667 }
668
669 #[test]
670 fn test_itunes_owner_default() {
671 let owner = ItunesOwner::default();
672 assert!(owner.name.is_none());
673 assert!(owner.email.is_none());
674 }
675
676 #[test]
677 #[allow(clippy::redundant_clone)]
678 fn test_itunes_category_clone() {
679 let category = ItunesCategory {
680 text: "Technology".to_string(),
681 subcategory: Some("Software".to_string()),
682 };
683 let cloned = category.clone();
684 assert_eq!(cloned.text, "Technology");
685 assert_eq!(cloned.subcategory.as_deref(), Some("Software"));
686 }
687
688 #[test]
689 fn test_podcast_meta_default() {
690 let meta = PodcastMeta::default();
691 assert!(meta.transcripts.is_empty());
692 assert!(meta.funding.is_empty());
693 assert!(meta.persons.is_empty());
694 assert!(meta.guid.is_none());
695 }
696
697 #[test]
698 #[allow(clippy::redundant_clone)]
699 fn test_podcast_transcript_clone() {
700 let transcript = PodcastTranscript {
701 url: "https://example.com/transcript.txt".to_string().into(),
702 transcript_type: Some("text/plain".to_string().into()),
703 language: Some("en".to_string()),
704 rel: None,
705 };
706 let cloned = transcript.clone();
707 assert_eq!(cloned.url, "https://example.com/transcript.txt");
708 assert_eq!(cloned.transcript_type.as_deref(), Some("text/plain"));
709 }
710
711 #[test]
712 #[allow(clippy::redundant_clone)]
713 fn test_podcast_funding_clone() {
714 let funding = PodcastFunding {
715 url: "https://example.com/donate".to_string().into(),
716 message: Some("Support us!".to_string()),
717 };
718 let cloned = funding.clone();
719 assert_eq!(cloned.url, "https://example.com/donate");
720 assert_eq!(cloned.message.as_deref(), Some("Support us!"));
721 }
722
723 #[test]
724 #[allow(clippy::redundant_clone)]
725 fn test_podcast_person_clone() {
726 let person = PodcastPerson {
727 name: "John Doe".to_string(),
728 role: Some("host".to_string()),
729 group: None,
730 img: Some("https://example.com/john.jpg".to_string().into()),
731 href: Some("https://example.com".to_string().into()),
732 };
733 let cloned = person.clone();
734 assert_eq!(cloned.name, "John Doe");
735 assert_eq!(cloned.role.as_deref(), Some("host"));
736 }
737
738 #[test]
739 fn test_podcast_chapters_default() {
740 let chapters = PodcastChapters::default();
741 assert!(chapters.url.is_empty());
742 assert!(chapters.type_.is_empty());
743 }
744
745 #[test]
746 #[allow(clippy::redundant_clone)]
747 fn test_podcast_chapters_clone() {
748 let chapters = PodcastChapters {
749 url: "https://example.com/chapters.json".to_string().into(),
750 type_: "application/json+chapters".to_string().into(),
751 };
752 let cloned = chapters.clone();
753 assert_eq!(cloned.url, "https://example.com/chapters.json");
754 assert_eq!(cloned.type_, "application/json+chapters");
755 }
756
757 #[test]
758 fn test_podcast_soundbite_default() {
759 let soundbite = PodcastSoundbite::default();
760 assert!((soundbite.start_time - 0.0).abs() < f64::EPSILON);
761 assert!((soundbite.duration - 0.0).abs() < f64::EPSILON);
762 assert!(soundbite.title.is_none());
763 }
764
765 #[test]
766 #[allow(clippy::redundant_clone)]
767 fn test_podcast_soundbite_clone() {
768 let soundbite = PodcastSoundbite {
769 start_time: 120.5,
770 duration: 30.0,
771 title: Some("Great quote".to_string()),
772 };
773 let cloned = soundbite.clone();
774 assert!((cloned.start_time - 120.5).abs() < f64::EPSILON);
775 assert!((cloned.duration - 30.0).abs() < f64::EPSILON);
776 assert_eq!(cloned.title.as_deref(), Some("Great quote"));
777 }
778
779 #[test]
780 fn test_podcast_entry_meta_default() {
781 let meta = PodcastEntryMeta::default();
782 assert!(meta.transcript.is_empty());
783 assert!(meta.chapters.is_none());
784 assert!(meta.soundbite.is_empty());
785 assert!(meta.person.is_empty());
786 }
787
788 #[test]
789 fn test_itunes_feed_meta_new_fields() {
790 let meta = ItunesFeedMeta {
791 complete: Some(true),
792 new_feed_url: Some("https://example.com/new-feed.xml".to_string().into()),
793 ..Default::default()
794 };
795
796 assert_eq!(meta.complete, Some(true));
797 assert_eq!(
798 meta.new_feed_url.as_deref(),
799 Some("https://example.com/new-feed.xml")
800 );
801 }
802
803 #[test]
804 fn test_podcast_value_default() {
805 let value = PodcastValue::default();
806 assert!(value.type_.is_empty());
807 assert!(value.method.is_empty());
808 assert!(value.suggested.is_none());
809 assert!(value.recipients.is_empty());
810 }
811
812 #[test]
813 fn test_podcast_value_lightning() {
814 let value = PodcastValue {
815 type_: "lightning".to_string(),
816 method: "keysend".to_string(),
817 suggested: Some("0.00000005000".to_string()),
818 recipients: vec![
819 PodcastValueRecipient {
820 name: Some("Host".to_string()),
821 type_: "node".to_string(),
822 address: "03ae9f91a0cb8ff43840e3c322c4c61f019d8c1c3cea15a25cfc425ac605e61a4a"
823 .to_string(),
824 split: 90,
825 fee: Some(false),
826 },
827 PodcastValueRecipient {
828 name: Some("Producer".to_string()),
829 type_: "node".to_string(),
830 address: "02d5c1bf8b940dc9cadca86d1b0a3c37fbe39cee4c7e839e33bef9174531d27f52"
831 .to_string(),
832 split: 10,
833 fee: Some(false),
834 },
835 ],
836 };
837
838 assert_eq!(value.type_, "lightning");
839 assert_eq!(value.method, "keysend");
840 assert_eq!(value.suggested.as_deref(), Some("0.00000005000"));
841 assert_eq!(value.recipients.len(), 2);
842 assert_eq!(value.recipients[0].split, 90);
843 assert_eq!(value.recipients[1].split, 10);
844 }
845
846 #[test]
847 fn test_podcast_value_recipient_default() {
848 let recipient = PodcastValueRecipient::default();
849 assert!(recipient.name.is_none());
850 assert!(recipient.type_.is_empty());
851 assert!(recipient.address.is_empty());
852 assert_eq!(recipient.split, 0);
853 assert!(recipient.fee.is_none());
854 }
855
856 #[test]
857 fn test_podcast_value_recipient_with_fee() {
858 let recipient = PodcastValueRecipient {
859 name: Some("Hosting Provider".to_string()),
860 type_: "node".to_string(),
861 address: "02d5c1bf8b940dc9cadca86d1b0a3c37fbe39cee4c7e839e33bef9174531d27f52"
862 .to_string(),
863 split: 5,
864 fee: Some(true),
865 };
866
867 assert_eq!(recipient.name.as_deref(), Some("Hosting Provider"));
868 assert_eq!(recipient.split, 5);
869 assert_eq!(recipient.fee, Some(true));
870 }
871
872 #[test]
873 fn test_podcast_value_recipient_without_name() {
874 let recipient = PodcastValueRecipient {
875 name: None,
876 type_: "node".to_string(),
877 address: "03ae9f91a0cb8ff43840e3c322c4c61f019d8c1c3cea15a25cfc425ac605e61a4a"
878 .to_string(),
879 split: 100,
880 fee: Some(false),
881 };
882
883 assert!(recipient.name.is_none());
884 assert_eq!(recipient.split, 100);
885 }
886
887 #[test]
888 fn test_podcast_value_multiple_recipients() {
889 let mut value = PodcastValue {
890 type_: "lightning".to_string(),
891 method: "keysend".to_string(),
892 suggested: None,
893 recipients: Vec::new(),
894 };
895
896 // Add multiple recipients
897 for i in 1..=5 {
898 value.recipients.push(PodcastValueRecipient {
899 name: Some(format!("Recipient {i}")),
900 type_: "node".to_string(),
901 address: format!("address_{i}"),
902 split: 20,
903 fee: Some(false),
904 });
905 }
906
907 assert_eq!(value.recipients.len(), 5);
908 assert_eq!(value.recipients.iter().map(|r| r.split).sum::<u32>(), 100);
909 }
910
911 #[test]
912 fn test_podcast_value_hive() {
913 let value = PodcastValue {
914 type_: "hive".to_string(),
915 method: "direct".to_string(),
916 suggested: Some("1.00000".to_string()),
917 recipients: vec![PodcastValueRecipient {
918 name: Some("@username".to_string()),
919 type_: "account".to_string(),
920 address: "username".to_string(),
921 split: 100,
922 fee: Some(false),
923 }],
924 };
925
926 assert_eq!(value.type_, "hive");
927 assert_eq!(value.method, "direct");
928 }
929
930 #[test]
931 fn test_podcast_meta_with_value() {
932 let mut meta = PodcastMeta::default();
933 assert!(meta.value.is_none());
934
935 meta.value = Some(PodcastValue {
936 type_: "lightning".to_string(),
937 method: "keysend".to_string(),
938 suggested: Some("0.00000005000".to_string()),
939 recipients: vec![],
940 });
941
942 assert!(meta.value.is_some());
943 assert_eq!(meta.value.as_ref().unwrap().type_, "lightning");
944 }
945
946 #[test]
947 #[allow(clippy::redundant_clone)]
948 fn test_podcast_value_clone() {
949 let value = PodcastValue {
950 type_: "lightning".to_string(),
951 method: "keysend".to_string(),
952 suggested: Some("0.00000005000".to_string()),
953 recipients: vec![PodcastValueRecipient {
954 name: Some("Host".to_string()),
955 type_: "node".to_string(),
956 address: "abc123".to_string(),
957 split: 100,
958 fee: Some(false),
959 }],
960 };
961
962 let cloned = value.clone();
963 assert_eq!(cloned.type_, "lightning");
964 assert_eq!(cloned.recipients.len(), 1);
965 assert_eq!(cloned.recipients[0].name.as_deref(), Some("Host"));
966 }
967}