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