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