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