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