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    /// Geographic location (podcast:location)
183    pub location: Option<PodcastLocation>,
184    /// Related feed references (podcast:podroll)
185    pub podroll: Vec<PodcastRemoteItem>,
186    /// Text records (podcast:txt)
187    pub txt: Vec<PodcastTxt>,
188    /// Update frequency schedule (podcast:updateFrequency)
189    pub update_frequency: Option<PodcastUpdateFrequency>,
190    /// Follow links (podcast:follow)
191    pub follow: Vec<PodcastFollow>,
192}
193
194/// Podcast 2.0 value element for monetization
195///
196/// Implements value-for-value payment model using cryptocurrency and streaming payments.
197/// Used for podcast monetization via Lightning Network, Hive, and other payment methods.
198///
199/// Namespace: `https://podcastindex.org/namespace/1.0`
200///
201/// # Examples
202///
203/// ```
204/// use feedparser_rs::{PodcastValue, PodcastValueRecipient};
205///
206/// let value = PodcastValue {
207///     type_: "lightning".to_string(),
208///     method: "keysend".to_string(),
209///     suggested: Some("0.00000005000".to_string()),
210///     recipients: vec![
211///         PodcastValueRecipient {
212///             name: Some("Host".to_string()),
213///             type_: "node".to_string(),
214///             address: "03ae9f91a0cb8ff43840e3c322c4c61f019d8c1c3cea15a25cfc425ac605e61a4a".to_string(),
215///             split: 90,
216///             fee: Some(false),
217///         },
218///         PodcastValueRecipient {
219///             name: Some("Producer".to_string()),
220///             type_: "node".to_string(),
221///             address: "02d5c1bf8b940dc9cadca86d1b0a3c37fbe39cee4c7e839e33bef9174531d27f52".to_string(),
222///             split: 10,
223///             fee: Some(false),
224///         },
225///     ],
226/// };
227///
228/// assert_eq!(value.type_, "lightning");
229/// assert_eq!(value.recipients.len(), 2);
230/// ```
231#[derive(Debug, Clone, Default, PartialEq, Eq)]
232pub struct PodcastValue {
233    /// Payment type (type attribute): "lightning", "hive", etc.
234    pub type_: String,
235    /// Payment method (method attribute): "keysend" for Lightning Network
236    pub method: String,
237    /// Suggested payment amount (suggested attribute)
238    ///
239    /// Format depends on payment type. For Lightning, this is typically satoshis.
240    pub suggested: Option<String>,
241    /// List of payment recipients with split percentages
242    pub recipients: Vec<PodcastValueRecipient>,
243}
244
245/// Value recipient for payment splitting
246///
247/// Defines a single recipient in the value-for-value payment model.
248/// Each recipient receives a percentage (split) of the total payment.
249///
250/// # Examples
251///
252/// ```
253/// use feedparser_rs::PodcastValueRecipient;
254///
255/// let recipient = PodcastValueRecipient {
256///     name: Some("Podcast Host".to_string()),
257///     type_: "node".to_string(),
258///     address: "03ae9f91a0cb8ff43840e3c322c4c61f019d8c1c3cea15a25cfc425ac605e61a4a".to_string(),
259///     split: 95,
260///     fee: Some(false),
261/// };
262///
263/// assert_eq!(recipient.split, 95);
264/// assert_eq!(recipient.fee, Some(false));
265/// ```
266#[derive(Debug, Clone, Default, PartialEq, Eq)]
267pub struct PodcastValueRecipient {
268    /// Recipient's name (name attribute)
269    pub name: Option<String>,
270    /// Recipient type (type attribute): "node" for Lightning Network nodes
271    pub type_: String,
272    /// Payment address (address attribute)
273    ///
274    /// For Lightning: node public key (hex-encoded)
275    /// For other types: appropriate address format
276    ///
277    /// # Security Warning
278    ///
279    /// This address comes from untrusted feed input. Applications MUST validate
280    /// addresses before sending payments to prevent sending funds to wrong recipients.
281    pub address: String,
282    /// Payment split percentage (split attribute)
283    ///
284    /// Can be absolute percentage (1-100) or relative value that's normalized.
285    /// Total of all splits should equal 100 for percentage-based splits.
286    pub split: u32,
287    /// Whether this is a fee recipient (fee attribute)
288    ///
289    /// Fee recipients are paid before regular splits are calculated.
290    pub fee: Option<bool>,
291}
292
293/// Podcast 2.0 transcript
294///
295/// Links to transcript files in various formats.
296///
297/// # Examples
298///
299/// ```
300/// use feedparser_rs::PodcastTranscript;
301///
302/// let transcript = PodcastTranscript {
303///     url: "https://example.com/transcript.txt".into(),
304///     transcript_type: Some("text/plain".into()),
305///     language: Some("en".to_string()),
306///     rel: None,
307/// };
308///
309/// assert_eq!(transcript.url, "https://example.com/transcript.txt");
310/// ```
311#[derive(Debug, Clone, PartialEq, Eq)]
312pub struct PodcastTranscript {
313    /// Transcript URL (url attribute)
314    ///
315    /// # Security Warning
316    ///
317    /// This URL comes from untrusted feed input and has NOT been validated for SSRF.
318    /// Applications MUST validate URLs before fetching to prevent SSRF attacks.
319    pub url: Url,
320    /// MIME type (type attribute): "text/plain", "text/html", "application/json", etc.
321    pub transcript_type: Option<MimeType>,
322    /// Language code (language attribute): "en", "es", etc.
323    pub language: Option<String>,
324    /// Relationship (rel attribute): "captions" or empty
325    pub rel: Option<String>,
326}
327
328/// Podcast 2.0 funding information
329///
330/// Links for supporting the podcast financially.
331///
332/// # Examples
333///
334/// ```
335/// use feedparser_rs::PodcastFunding;
336///
337/// let funding = PodcastFunding {
338///     url: "https://example.com/donate".into(),
339///     message: Some("Support our show!".to_string()),
340/// };
341///
342/// assert_eq!(funding.url, "https://example.com/donate");
343/// ```
344#[derive(Debug, Clone)]
345pub struct PodcastFunding {
346    /// Funding URL (url attribute)
347    ///
348    /// # Security Warning
349    ///
350    /// This URL comes from untrusted feed input and has NOT been validated for SSRF.
351    /// Applications MUST validate URLs before fetching to prevent SSRF attacks.
352    pub url: Url,
353    /// Optional message/call-to-action (text content)
354    pub message: Option<String>,
355}
356
357/// Podcast 2.0 person
358///
359/// Information about hosts, guests, or other people associated with the podcast.
360///
361/// # Examples
362///
363/// ```
364/// use feedparser_rs::PodcastPerson;
365///
366/// let host = PodcastPerson {
367///     name: "John Doe".to_string(),
368///     role: Some("host".to_string()),
369///     group: None,
370///     img: Some("https://example.com/john.jpg".into()),
371///     href: Some("https://example.com/john".into()),
372/// };
373///
374/// assert_eq!(host.name, "John Doe");
375/// assert_eq!(host.role.as_deref(), Some("host"));
376/// ```
377#[derive(Debug, Clone, PartialEq, Eq)]
378pub struct PodcastPerson {
379    /// Person's name (text content)
380    pub name: String,
381    /// Role: "host", "guest", "editor", etc. (role attribute)
382    pub role: Option<String>,
383    /// Group name (group attribute)
384    pub group: Option<String>,
385    /// Image URL (img attribute)
386    ///
387    /// # Security Warning
388    ///
389    /// This URL comes from untrusted feed input and has NOT been validated for SSRF.
390    /// Applications MUST validate URLs before fetching to prevent SSRF attacks.
391    pub img: Option<Url>,
392    /// Personal URL/homepage (href attribute)
393    ///
394    /// # Security Warning
395    ///
396    /// This URL comes from untrusted feed input and has NOT been validated for SSRF.
397    /// Applications MUST validate URLs before fetching to prevent SSRF attacks.
398    pub href: Option<Url>,
399}
400
401/// Podcast 2.0 chapters information
402///
403/// Links to chapter markers for time-based navigation within an episode.
404/// Namespace: `https://podcastindex.org/namespace/1.0`
405///
406/// # Examples
407///
408/// ```
409/// use feedparser_rs::PodcastChapters;
410///
411/// let chapters = PodcastChapters {
412///     url: "https://example.com/chapters.json".into(),
413///     type_: "application/json+chapters".into(),
414/// };
415///
416/// assert_eq!(chapters.url, "https://example.com/chapters.json");
417/// ```
418#[derive(Debug, Clone, Default, PartialEq, Eq)]
419pub struct PodcastChapters {
420    /// Chapters file URL (url attribute)
421    ///
422    /// # Security Warning
423    ///
424    /// This URL comes from untrusted feed input and has NOT been validated for SSRF.
425    /// Applications MUST validate URLs before fetching to prevent SSRF attacks.
426    pub url: Url,
427    /// MIME type (type attribute): "application/json+chapters" or "application/xml+chapters"
428    pub type_: MimeType,
429}
430
431/// Podcast 2.0 soundbite (shareable clip)
432///
433/// Marks a portion of the audio for social sharing or highlights.
434/// Namespace: `https://podcastindex.org/namespace/1.0`
435///
436/// # Examples
437///
438/// ```
439/// use feedparser_rs::PodcastSoundbite;
440///
441/// let soundbite = PodcastSoundbite {
442///     start_time: 120.5,
443///     duration: 30.0,
444///     title: Some("Great quote".to_string()),
445/// };
446///
447/// assert_eq!(soundbite.start_time, 120.5);
448/// assert_eq!(soundbite.duration, 30.0);
449/// ```
450#[derive(Debug, Clone, Default, PartialEq)]
451#[allow(clippy::derive_partial_eq_without_eq)]
452pub struct PodcastSoundbite {
453    /// Start time in seconds (startTime attribute)
454    pub start_time: f64,
455    /// Duration in seconds (duration attribute)
456    pub duration: f64,
457    /// Optional title/description (text content)
458    pub title: Option<String>,
459}
460
461/// Podcast 2.0 alternate enclosure source
462///
463/// A single source URI within a `podcast:alternateEnclosure` element.
464#[derive(Debug, Clone, Default, PartialEq, Eq)]
465pub struct PodcastAlternateEnclosureSource {
466    /// Source URI (uri attribute, required)
467    ///
468    /// # Security Warning
469    ///
470    /// This URL comes from untrusted feed input and has NOT been validated for SSRF.
471    /// Applications MUST validate URLs before fetching to prevent SSRF attacks.
472    pub uri: Url,
473    /// Optional MIME type override (contentType attribute)
474    pub content_type: Option<MimeType>,
475}
476
477/// Podcast 2.0 integrity verification for alternate enclosures
478///
479/// Cryptographic integrity check for enclosure sources.
480#[derive(Debug, Clone, Default, PartialEq, Eq)]
481pub struct PodcastIntegrity {
482    /// Integrity type (type attribute): "sri" or "pgp-signature"
483    pub type_: String,
484    /// Integrity value (text content)
485    pub value: String,
486}
487
488/// Podcast 2.0 alternate enclosure
489///
490/// An alternate version of the main episode audio/video in a different format or quality.
491///
492/// Namespace: `https://podcastindex.org/namespace/1.0`
493#[derive(Debug, Clone, Default, PartialEq)]
494#[allow(clippy::derive_partial_eq_without_eq)]
495pub struct PodcastAlternateEnclosure {
496    /// MIME type (type attribute, required)
497    pub type_: MimeType,
498    /// File size in bytes (length attribute)
499    pub length: Option<u64>,
500    /// Bitrate in kbps (bitrate attribute)
501    pub bitrate: Option<f64>,
502    /// Video height in pixels (height attribute)
503    pub height: Option<u32>,
504    /// Language code (lang attribute)
505    pub lang: Option<String>,
506    /// Title (title attribute)
507    pub title: Option<String>,
508    /// Relationship (rel attribute): "default", "alternate", etc.
509    pub rel: Option<String>,
510    /// Codecs string (codecs attribute)
511    pub codecs: Option<String>,
512    /// Whether this is the default enclosure (default attribute)
513    pub default: Option<bool>,
514    /// Source URIs for this enclosure
515    pub sources: Vec<PodcastAlternateEnclosureSource>,
516    /// Integrity verification
517    pub integrity: Option<PodcastIntegrity>,
518}
519
520/// Podcast 2.0 geographic location
521///
522/// Location information for a podcast or episode.
523///
524/// Namespace: `https://podcastindex.org/namespace/1.0`
525#[derive(Debug, Clone, Default, PartialEq, Eq)]
526pub struct PodcastLocation {
527    /// Human-readable location name (text content)
528    pub name: String,
529    /// Geographic coordinates (geo attribute): "geo:37.786971,-122.399677"
530    pub geo: Option<String>,
531    /// OpenStreetMap reference (osm attribute): "R113314"
532    pub osm: Option<String>,
533}
534
535/// Podcast 2.0 remote item reference
536///
537/// A reference to a remote podcast feed or episode, used within `podcast:podroll`.
538#[derive(Debug, Clone, Default, PartialEq, Eq)]
539pub struct PodcastRemoteItem {
540    /// Feed GUID (feedGuid attribute)
541    pub feed_guid: Option<String>,
542    /// Feed URL (feedUrl attribute)
543    ///
544    /// # Security Warning
545    ///
546    /// This URL comes from untrusted feed input and has NOT been validated for SSRF.
547    pub feed_url: Option<Url>,
548    /// Item GUID (itemGuid attribute)
549    pub item_guid: Option<String>,
550    /// Content medium type (medium attribute)
551    pub medium: Option<String>,
552    /// Display title (title attribute)
553    pub title: Option<String>,
554}
555
556/// Podcast 2.0 social interaction
557///
558/// Links a podcast episode to a social media thread.
559///
560/// Namespace: `https://podcastindex.org/namespace/1.0`
561#[derive(Debug, Clone, Default, PartialEq, Eq)]
562pub struct PodcastSocialInteract {
563    /// Social thread URI (uri attribute, required)
564    ///
565    /// # Security Warning
566    ///
567    /// This URL comes from untrusted feed input and has NOT been validated for SSRF.
568    pub uri: Url,
569    /// Social protocol (protocol attribute): "activitypub", "twitter", etc.
570    pub protocol: Option<String>,
571    /// Account identifier (accountId attribute)
572    pub account_id: Option<String>,
573    /// Account URL (accountUrl attribute)
574    ///
575    /// # Security Warning
576    ///
577    /// This URL comes from untrusted feed input and has NOT been validated for SSRF.
578    pub account_url: Option<Url>,
579    /// Priority (priority attribute, lower = higher priority)
580    pub priority: Option<u32>,
581}
582
583/// Podcast 2.0 text record
584///
585/// Arbitrary text metadata with an optional purpose tag.
586///
587/// Namespace: `https://podcastindex.org/namespace/1.0`
588#[derive(Debug, Clone, Default, PartialEq, Eq)]
589pub struct PodcastTxt {
590    /// Purpose of the text (purpose attribute)
591    pub purpose: Option<String>,
592    /// Text content
593    pub value: String,
594}
595
596/// Podcast 2.0 update frequency
597///
598/// Indicates how often a podcast publishes new episodes.
599///
600/// Namespace: `https://podcastindex.org/namespace/1.0`
601#[derive(Debug, Clone, Default, PartialEq, Eq)]
602pub struct PodcastUpdateFrequency {
603    /// iCalendar RRULE string (rrule attribute)
604    pub rrule: Option<String>,
605    /// Whether the podcast is complete (complete attribute)
606    pub complete: Option<bool>,
607    /// Start date in ISO 8601 (dtstart attribute)
608    pub dtstart: Option<String>,
609    /// Human-readable label (text content)
610    pub label: Option<String>,
611}
612
613/// Podcast 2.0 follow link
614///
615/// A URL and optional platform for following the podcast.
616///
617/// Namespace: `https://podcastindex.org/namespace/1.0`
618#[derive(Debug, Clone, Default, PartialEq, Eq)]
619pub struct PodcastFollow {
620    /// Follow URL (url attribute, required)
621    ///
622    /// # Security Warning
623    ///
624    /// This URL comes from untrusted feed input and has NOT been validated for SSRF.
625    pub url: Url,
626    /// Platform name (platform attribute)
627    pub platform: Option<String>,
628}
629
630/// Podcast 2.0 metadata for episodes
631///
632/// Container for entry-level podcast metadata.
633///
634/// # Examples
635///
636/// ```
637/// use feedparser_rs::PodcastEntryMeta;
638///
639/// let mut podcast = PodcastEntryMeta::default();
640/// assert!(podcast.transcript.is_empty());
641/// assert!(podcast.chapters.is_none());
642/// assert!(podcast.soundbite.is_empty());
643/// ```
644#[derive(Debug, Clone, Default, PartialEq)]
645pub struct PodcastEntryMeta {
646    /// Transcript URLs (podcast:transcript)
647    pub transcript: Vec<PodcastTranscript>,
648    /// Chapter markers (podcast:chapters)
649    pub chapters: Option<PodcastChapters>,
650    /// Shareable soundbites (podcast:soundbite)
651    pub soundbite: Vec<PodcastSoundbite>,
652    /// People associated with this episode (podcast:person)
653    pub persons: Vec<PodcastPerson>,
654    /// Content medium type (podcast:medium)
655    pub medium: Option<String>,
656    /// Season number (podcast:season number attribute)
657    pub season: Option<String>,
658    /// Episode number (podcast:episode number attribute)
659    pub episode: Option<String>,
660    /// Alternate enclosures (podcast:alternateEnclosure)
661    pub alternate_enclosures: Vec<PodcastAlternateEnclosure>,
662    /// Geographic location (podcast:location)
663    pub location: Option<PodcastLocation>,
664    /// Social interaction threads (podcast:socialInteract)
665    pub social_interact: Vec<PodcastSocialInteract>,
666    /// Text records (podcast:txt)
667    pub txt: Vec<PodcastTxt>,
668    /// Follow links (podcast:follow)
669    pub follow: Vec<PodcastFollow>,
670}
671
672/// Parse iTunes explicit flag from various string representations
673///
674/// Maps "yes"/"true"/"explicit" to `Some(true)`.
675/// Maps "no"/"false"/"clean" and absent values to `None` (per Python feedparser compatibility).
676///
677/// Case-insensitive matching.
678///
679/// # Arguments
680///
681/// * `s` - Explicit flag string
682///
683/// # Examples
684///
685/// ```
686/// use feedparser_rs::parse_explicit;
687///
688/// assert_eq!(parse_explicit("yes"), Some(true));
689/// assert_eq!(parse_explicit("YES"), Some(true));
690/// assert_eq!(parse_explicit("true"), Some(true));
691/// assert_eq!(parse_explicit("explicit"), Some(true));
692///
693/// assert_eq!(parse_explicit("no"), None);
694/// assert_eq!(parse_explicit("false"), None);
695/// assert_eq!(parse_explicit("clean"), None);
696///
697/// assert_eq!(parse_explicit("unknown"), None);
698/// ```
699pub fn parse_explicit(s: &str) -> Option<bool> {
700    let s = s.trim();
701    if s.eq_ignore_ascii_case("yes")
702        || s.eq_ignore_ascii_case("true")
703        || s.eq_ignore_ascii_case("explicit")
704    {
705        Some(true)
706    } else {
707        None
708    }
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714
715    #[test]
716    fn test_parse_explicit_true_variants() {
717        assert_eq!(parse_explicit("yes"), Some(true));
718        assert_eq!(parse_explicit("YES"), Some(true));
719        assert_eq!(parse_explicit("Yes"), Some(true));
720        assert_eq!(parse_explicit("true"), Some(true));
721        assert_eq!(parse_explicit("TRUE"), Some(true));
722        assert_eq!(parse_explicit("explicit"), Some(true));
723        assert_eq!(parse_explicit("EXPLICIT"), Some(true));
724    }
725
726    #[test]
727    fn test_parse_explicit_false_variants_return_none() {
728        // "no"/"false"/"clean" → None (Python feedparser compat: only "yes" is truthy)
729        assert_eq!(parse_explicit("no"), None);
730        assert_eq!(parse_explicit("NO"), None);
731        assert_eq!(parse_explicit("No"), None);
732        assert_eq!(parse_explicit("false"), None);
733        assert_eq!(parse_explicit("FALSE"), None);
734        assert_eq!(parse_explicit("clean"), None);
735        assert_eq!(parse_explicit("CLEAN"), None);
736    }
737
738    #[test]
739    fn test_parse_explicit_whitespace() {
740        assert_eq!(parse_explicit("  yes  "), Some(true));
741        assert_eq!(parse_explicit("  no  "), None);
742    }
743
744    #[test]
745    fn test_parse_explicit_unknown() {
746        assert_eq!(parse_explicit("unknown"), None);
747        assert_eq!(parse_explicit("maybe"), None);
748        assert_eq!(parse_explicit(""), None);
749        assert_eq!(parse_explicit("1"), None);
750    }
751
752    #[test]
753    fn test_itunes_feed_meta_default() {
754        let meta = ItunesFeedMeta::default();
755        assert!(meta.author.is_none());
756        assert!(meta.owner.is_none());
757        assert!(meta.categories.is_empty());
758        assert!(meta.explicit.is_none());
759        assert!(meta.image.is_none());
760        assert!(meta.keywords.is_empty());
761        assert!(meta.podcast_type.is_none());
762        assert!(meta.complete.is_none());
763        assert!(meta.new_feed_url.is_none());
764    }
765
766    #[test]
767    fn test_itunes_entry_meta_default() {
768        let meta = ItunesEntryMeta::default();
769        assert!(meta.title.is_none());
770        assert!(meta.author.is_none());
771        assert!(meta.duration.is_none());
772        assert!(meta.explicit.is_none());
773        assert!(meta.image.is_none());
774        assert!(meta.episode.is_none());
775        assert!(meta.season.is_none());
776        assert!(meta.episode_type.is_none());
777    }
778
779    #[test]
780    fn test_itunes_entry_meta_string_fields() {
781        let meta = ItunesEntryMeta {
782            duration: Some("1:23:45".to_string()),
783            episode: Some("42".to_string()),
784            season: Some("3".to_string()),
785            ..Default::default()
786        };
787        assert_eq!(meta.duration.as_deref(), Some("1:23:45"));
788        assert_eq!(meta.episode.as_deref(), Some("42"));
789        assert_eq!(meta.season.as_deref(), Some("3"));
790    }
791
792    #[test]
793    fn test_itunes_owner_default() {
794        let owner = ItunesOwner::default();
795        assert!(owner.name.is_none());
796        assert!(owner.email.is_none());
797    }
798
799    #[test]
800    #[allow(clippy::redundant_clone)]
801    fn test_itunes_category_clone() {
802        let category = ItunesCategory {
803            text: "Technology".to_string(),
804            subcategory: Some("Software".to_string()),
805        };
806        let cloned = category.clone();
807        assert_eq!(cloned.text, "Technology");
808        assert_eq!(cloned.subcategory.as_deref(), Some("Software"));
809    }
810
811    #[test]
812    fn test_podcast_meta_default() {
813        let meta = PodcastMeta::default();
814        assert!(meta.transcripts.is_empty());
815        assert!(meta.funding.is_empty());
816        assert!(meta.persons.is_empty());
817        assert!(meta.guid.is_none());
818        assert!(meta.location.is_none());
819        assert!(meta.podroll.is_empty());
820        assert!(meta.txt.is_empty());
821        assert!(meta.update_frequency.is_none());
822        assert!(meta.follow.is_empty());
823    }
824
825    #[test]
826    fn test_podcast_entry_meta_new_fields_default() {
827        let meta = PodcastEntryMeta::default();
828        assert!(meta.alternate_enclosures.is_empty());
829        assert!(meta.location.is_none());
830        assert!(meta.social_interact.is_empty());
831        assert!(meta.txt.is_empty());
832        assert!(meta.follow.is_empty());
833    }
834
835    #[test]
836    fn test_podcast_location_default() {
837        let loc = PodcastLocation::default();
838        assert!(loc.name.is_empty());
839        assert!(loc.geo.is_none());
840        assert!(loc.osm.is_none());
841    }
842
843    #[test]
844    fn test_podcast_social_interact_default() {
845        let si = PodcastSocialInteract::default();
846        assert!(si.uri.is_empty());
847        assert!(si.protocol.is_none());
848        assert!(si.account_id.is_none());
849        assert!(si.account_url.is_none());
850        assert!(si.priority.is_none());
851    }
852
853    #[test]
854    fn test_podcast_txt_default() {
855        let txt = PodcastTxt::default();
856        assert!(txt.purpose.is_none());
857        assert!(txt.value.is_empty());
858    }
859
860    #[test]
861    fn test_podcast_update_frequency_default() {
862        let uf = PodcastUpdateFrequency::default();
863        assert!(uf.rrule.is_none());
864        assert!(uf.complete.is_none());
865        assert!(uf.dtstart.is_none());
866        assert!(uf.label.is_none());
867    }
868
869    #[test]
870    fn test_podcast_follow_default() {
871        let f = PodcastFollow::default();
872        assert!(f.url.is_empty());
873        assert!(f.platform.is_none());
874    }
875
876    #[test]
877    fn test_podcast_remote_item_default() {
878        let item = PodcastRemoteItem::default();
879        assert!(item.feed_guid.is_none());
880        assert!(item.feed_url.is_none());
881        assert!(item.item_guid.is_none());
882        assert!(item.medium.is_none());
883        assert!(item.title.is_none());
884    }
885
886    #[test]
887    fn test_podcast_alternate_enclosure_default() {
888        let ae = PodcastAlternateEnclosure::default();
889        assert!(ae.type_.is_empty());
890        assert!(ae.length.is_none());
891        assert!(ae.bitrate.is_none());
892        assert!(ae.sources.is_empty());
893        assert!(ae.integrity.is_none());
894    }
895
896    #[test]
897    #[allow(clippy::redundant_clone)]
898    fn test_podcast_transcript_clone() {
899        let transcript = PodcastTranscript {
900            url: "https://example.com/transcript.txt".to_string().into(),
901            transcript_type: Some("text/plain".to_string().into()),
902            language: Some("en".to_string()),
903            rel: None,
904        };
905        let cloned = transcript.clone();
906        assert_eq!(cloned.url, "https://example.com/transcript.txt");
907        assert_eq!(cloned.transcript_type.as_deref(), Some("text/plain"));
908    }
909
910    #[test]
911    #[allow(clippy::redundant_clone)]
912    fn test_podcast_funding_clone() {
913        let funding = PodcastFunding {
914            url: "https://example.com/donate".to_string().into(),
915            message: Some("Support us!".to_string()),
916        };
917        let cloned = funding.clone();
918        assert_eq!(cloned.url, "https://example.com/donate");
919        assert_eq!(cloned.message.as_deref(), Some("Support us!"));
920    }
921
922    #[test]
923    #[allow(clippy::redundant_clone)]
924    fn test_podcast_person_clone() {
925        let person = PodcastPerson {
926            name: "John Doe".to_string(),
927            role: Some("host".to_string()),
928            group: None,
929            img: Some("https://example.com/john.jpg".to_string().into()),
930            href: Some("https://example.com".to_string().into()),
931        };
932        let cloned = person.clone();
933        assert_eq!(cloned.name, "John Doe");
934        assert_eq!(cloned.role.as_deref(), Some("host"));
935    }
936
937    #[test]
938    fn test_podcast_chapters_default() {
939        let chapters = PodcastChapters::default();
940        assert!(chapters.url.is_empty());
941        assert!(chapters.type_.is_empty());
942    }
943
944    #[test]
945    #[allow(clippy::redundant_clone)]
946    fn test_podcast_chapters_clone() {
947        let chapters = PodcastChapters {
948            url: "https://example.com/chapters.json".to_string().into(),
949            type_: "application/json+chapters".to_string().into(),
950        };
951        let cloned = chapters.clone();
952        assert_eq!(cloned.url, "https://example.com/chapters.json");
953        assert_eq!(cloned.type_, "application/json+chapters");
954    }
955
956    #[test]
957    fn test_podcast_soundbite_default() {
958        let soundbite = PodcastSoundbite::default();
959        assert!((soundbite.start_time - 0.0).abs() < f64::EPSILON);
960        assert!((soundbite.duration - 0.0).abs() < f64::EPSILON);
961        assert!(soundbite.title.is_none());
962    }
963
964    #[test]
965    #[allow(clippy::redundant_clone)]
966    fn test_podcast_soundbite_clone() {
967        let soundbite = PodcastSoundbite {
968            start_time: 120.5,
969            duration: 30.0,
970            title: Some("Great quote".to_string()),
971        };
972        let cloned = soundbite.clone();
973        assert!((cloned.start_time - 120.5).abs() < f64::EPSILON);
974        assert!((cloned.duration - 30.0).abs() < f64::EPSILON);
975        assert_eq!(cloned.title.as_deref(), Some("Great quote"));
976    }
977
978    #[test]
979    fn test_podcast_entry_meta_default() {
980        let meta = PodcastEntryMeta::default();
981        assert!(meta.transcript.is_empty());
982        assert!(meta.chapters.is_none());
983        assert!(meta.soundbite.is_empty());
984        assert!(meta.persons.is_empty());
985        assert!(meta.medium.is_none());
986    }
987
988    #[test]
989    fn test_itunes_feed_meta_new_fields() {
990        let meta = ItunesFeedMeta {
991            complete: Some("Yes".to_string()),
992            new_feed_url: Some("https://example.com/new-feed.xml".to_string().into()),
993            ..Default::default()
994        };
995
996        assert_eq!(meta.complete.as_deref(), Some("Yes"));
997        assert_eq!(
998            meta.new_feed_url.as_deref(),
999            Some("https://example.com/new-feed.xml")
1000        );
1001    }
1002
1003    #[test]
1004    fn test_podcast_value_default() {
1005        let value = PodcastValue::default();
1006        assert!(value.type_.is_empty());
1007        assert!(value.method.is_empty());
1008        assert!(value.suggested.is_none());
1009        assert!(value.recipients.is_empty());
1010    }
1011
1012    #[test]
1013    fn test_podcast_value_lightning() {
1014        let value = PodcastValue {
1015            type_: "lightning".to_string(),
1016            method: "keysend".to_string(),
1017            suggested: Some("0.00000005000".to_string()),
1018            recipients: vec![
1019                PodcastValueRecipient {
1020                    name: Some("Host".to_string()),
1021                    type_: "node".to_string(),
1022                    address: "03ae9f91a0cb8ff43840e3c322c4c61f019d8c1c3cea15a25cfc425ac605e61a4a"
1023                        .to_string(),
1024                    split: 90,
1025                    fee: Some(false),
1026                },
1027                PodcastValueRecipient {
1028                    name: Some("Producer".to_string()),
1029                    type_: "node".to_string(),
1030                    address: "02d5c1bf8b940dc9cadca86d1b0a3c37fbe39cee4c7e839e33bef9174531d27f52"
1031                        .to_string(),
1032                    split: 10,
1033                    fee: Some(false),
1034                },
1035            ],
1036        };
1037
1038        assert_eq!(value.type_, "lightning");
1039        assert_eq!(value.method, "keysend");
1040        assert_eq!(value.suggested.as_deref(), Some("0.00000005000"));
1041        assert_eq!(value.recipients.len(), 2);
1042        assert_eq!(value.recipients[0].split, 90);
1043        assert_eq!(value.recipients[1].split, 10);
1044    }
1045
1046    #[test]
1047    fn test_podcast_value_recipient_default() {
1048        let recipient = PodcastValueRecipient::default();
1049        assert!(recipient.name.is_none());
1050        assert!(recipient.type_.is_empty());
1051        assert!(recipient.address.is_empty());
1052        assert_eq!(recipient.split, 0);
1053        assert!(recipient.fee.is_none());
1054    }
1055
1056    #[test]
1057    fn test_podcast_value_recipient_with_fee() {
1058        let recipient = PodcastValueRecipient {
1059            name: Some("Hosting Provider".to_string()),
1060            type_: "node".to_string(),
1061            address: "02d5c1bf8b940dc9cadca86d1b0a3c37fbe39cee4c7e839e33bef9174531d27f52"
1062                .to_string(),
1063            split: 5,
1064            fee: Some(true),
1065        };
1066
1067        assert_eq!(recipient.name.as_deref(), Some("Hosting Provider"));
1068        assert_eq!(recipient.split, 5);
1069        assert_eq!(recipient.fee, Some(true));
1070    }
1071
1072    #[test]
1073    fn test_podcast_value_recipient_without_name() {
1074        let recipient = PodcastValueRecipient {
1075            name: None,
1076            type_: "node".to_string(),
1077            address: "03ae9f91a0cb8ff43840e3c322c4c61f019d8c1c3cea15a25cfc425ac605e61a4a"
1078                .to_string(),
1079            split: 100,
1080            fee: Some(false),
1081        };
1082
1083        assert!(recipient.name.is_none());
1084        assert_eq!(recipient.split, 100);
1085    }
1086
1087    #[test]
1088    fn test_podcast_value_multiple_recipients() {
1089        let mut value = PodcastValue {
1090            type_: "lightning".to_string(),
1091            method: "keysend".to_string(),
1092            suggested: None,
1093            recipients: Vec::new(),
1094        };
1095
1096        // Add multiple recipients
1097        for i in 1..=5 {
1098            value.recipients.push(PodcastValueRecipient {
1099                name: Some(format!("Recipient {i}")),
1100                type_: "node".to_string(),
1101                address: format!("address_{i}"),
1102                split: 20,
1103                fee: Some(false),
1104            });
1105        }
1106
1107        assert_eq!(value.recipients.len(), 5);
1108        assert_eq!(value.recipients.iter().map(|r| r.split).sum::<u32>(), 100);
1109    }
1110
1111    #[test]
1112    fn test_podcast_value_hive() {
1113        let value = PodcastValue {
1114            type_: "hive".to_string(),
1115            method: "direct".to_string(),
1116            suggested: Some("1.00000".to_string()),
1117            recipients: vec![PodcastValueRecipient {
1118                name: Some("@username".to_string()),
1119                type_: "account".to_string(),
1120                address: "username".to_string(),
1121                split: 100,
1122                fee: Some(false),
1123            }],
1124        };
1125
1126        assert_eq!(value.type_, "hive");
1127        assert_eq!(value.method, "direct");
1128    }
1129
1130    #[test]
1131    fn test_podcast_meta_with_value() {
1132        let mut meta = PodcastMeta::default();
1133        assert!(meta.value.is_none());
1134
1135        meta.value = Some(PodcastValue {
1136            type_: "lightning".to_string(),
1137            method: "keysend".to_string(),
1138            suggested: Some("0.00000005000".to_string()),
1139            recipients: vec![],
1140        });
1141
1142        assert!(meta.value.is_some());
1143        assert_eq!(meta.value.as_ref().unwrap().type_, "lightning");
1144    }
1145
1146    #[test]
1147    #[allow(clippy::redundant_clone)]
1148    fn test_podcast_value_clone() {
1149        let value = PodcastValue {
1150            type_: "lightning".to_string(),
1151            method: "keysend".to_string(),
1152            suggested: Some("0.00000005000".to_string()),
1153            recipients: vec![PodcastValueRecipient {
1154                name: Some("Host".to_string()),
1155                type_: "node".to_string(),
1156                address: "abc123".to_string(),
1157                split: 100,
1158                fee: Some(false),
1159            }],
1160        };
1161
1162        let cloned = value.clone();
1163        assert_eq!(cloned.type_, "lightning");
1164        assert_eq!(cloned.recipients.len(), 1);
1165        assert_eq!(cloned.recipients[0].name.as_deref(), Some("Host"));
1166    }
1167}