Skip to main content

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}