Skip to main content

feed_rs/model/
mod.rs

1use std::fmt::Debug;
2use std::time::Duration;
3
4use chrono::{DateTime, Utc};
5use mediatype::{MediaTypeBuf, names};
6use serde::de::DeserializeOwned;
7use serde::{Deserialize, Deserializer, Serialize};
8use serde_json::Value;
9use url::Url;
10
11#[cfg(test)]
12use crate::parser::util::parse_timestamp_lenient;
13use crate::parser::{ParseErrorKind, ParseFeedError, util};
14
15/// Combined model for a syndication feed (i.e. RSS1, RSS 2, Atom, JSON Feed)
16///
17/// The model is based on the Atom standard as a start with RSS1+2 mapped on to it e.g.
18/// * Atom
19///     * Feed -> Feed
20///     * Entry -> Entry
21/// * RSS 1 + 2
22///     * Channel -> Feed
23///     * Item -> Entry
24///
25/// [Atom spec]: http://www.atomenabled.org/developers/syndication/
26/// [RSS 2 spec]: https://validator.w3.org/feed/docs/rss2.html
27/// [RSS 1 spec]: https://validator.w3.org/feed/docs/rss1.html
28/// [MediaRSS spec]: https://www.rssboard.org/media-rss
29/// [iTunes podcast spec]: https://help.apple.com/itc/podcasts_connect/#/itcb54353390
30/// [iTunes podcast guide]: https://www.feedforall.com/itune-tutorial-tags.htm
31///
32/// Certain elements are not mapped given their limited utility:
33///   * RSS 2:
34///     * channel - docs (pointer to the spec), cloud (for callbacks), textInput (text box e.g. for search)
35///     * item - source (pointer to the channel, but our data model links items to a channel)
36///   * RSS 1:
37///     * channel - rdf:about attribute (pointer to feed), textinput (text box e.g. for search)
38#[derive(Clone, Deserialize, PartialEq, Serialize)]
39pub struct Feed {
40    /// Type of this feed (e.g. RSS2, Atom etc)
41    pub feed_type: FeedType,
42    /// A unique identifier for this feed
43    /// * Atom (required): Identifies the feed using a universally unique and permanent URI.
44    /// * RSS doesn't require an ID so it is initialised to the hash of the first link or a UUID if not found
45    pub id: String,
46    /// The title of the feed
47    /// * Atom (required): Contains a human readable title for the feed. Often the same as the title of the associated website. This value should not be blank.
48    /// * RSS 1 + 2 (required) "title": The name of the channel. It's how people refer to your service.
49    /// * JSON Feed: is the name of the feed
50    pub title: Option<Text>,
51    /// The time at which the feed was last modified. If not provided in the source, or invalid, it is `None`.
52    /// * Atom (required): Indicates the last time the feed was modified in a significant way.
53    /// * RSS 2 (optional) "lastBuildDate": The last time the content of the channel changed.
54    pub updated: Option<DateTime<Utc>>,
55
56    /// Atom (recommended): Collection of authors defined at the feed level.
57    /// JSON Feed: specifies the feed author.
58    pub authors: Vec<Person>,
59    /// Description of the feed
60    /// * Atom (optional): Contains a human-readable description or subtitle for the feed (from the subtitle element).
61    /// * RSS 1 + 2 (required): Phrase or sentence describing the channel.
62    /// * JSON Feed: description of the feed
63    pub description: Option<Text>,
64    /// Links to related pages
65    /// * Atom (recommended): Identifies a related Web page.
66    /// * RSS 1 + 2 (required): The URL to the HTML website corresponding to the channel.
67    /// * JSON Feed: the homepage and feed URLs
68    pub links: Vec<Link>,
69
70    /// Structured classification of the feed
71    /// * Atom (optional): Specifies a category that the feed belongs to. A feed may have multiple category elements.
72    /// * RSS 2 (optional) "category": Specify one or more categories that the channel belongs to.
73    pub categories: Vec<Category>,
74    /// People who have contributed to the feed
75    /// * Atom (optional): Names one contributor to the feed. A feed may have multiple contributor elements.
76    /// * RSS 2 (optional) "managingEditor": Email address for person responsible for editorial content.
77    /// * RSS 2 (optional) "webMaster": Email address for person responsible for technical issues relating to channel.
78    pub contributors: Vec<Person>,
79    /// Information on the software used to build the feed
80    /// * Atom (optional): Identifies the software used to generate the feed, for debugging and other purposes.
81    /// * RSS 2 (optional): A string indicating the program used to generate the channel.
82    pub generator: Option<Generator>,
83    /// A small icon
84    /// * Atom (optional): Identifies a small image which provides iconic visual identification for the feed.
85    /// * JSON Feed: is the URL of an image for the feed suitable to be used in a source list.
86    pub icon: Option<Image>,
87    /// RSS 2 (optional): The language the channel is written in.
88    pub language: Option<String>,
89    /// An image used to visually identify the feed
90    /// * Atom (optional): Identifies a larger image which provides visual identification for the feed.
91    /// * RSS 1 + 2 (optional) "image": Specifies a GIF, JPEG or PNG image that can be displayed with the channel.
92    /// * JSON Feed: is the URL of an image for the feed suitable to be used in a timeline
93    pub logo: Option<Image>,
94    /// RSS 2 (optional): The publication date for the content in the channel.
95    pub published: Option<DateTime<Utc>>,
96    /// Rating for the content
97    /// * Populated from the media or itunes namespaces
98    pub rating: Option<MediaRating>,
99    /// Rights restricting content within the feed
100    /// * Atom (optional): Conveys information about rights, e.g. copyrights, held in and over the feed.
101    /// * RSS 2 (optional) "copyright": Copyright notice for content in the channel.
102    pub rights: Option<Text>,
103    /// RSS 2 (optional): It's a number of minutes that indicates how long a channel can be cached before refreshing from the source.
104    pub ttl: Option<u32>,
105    /// Podcast: People associated with this channel
106    pub people: Vec<PodcastPerson>,
107
108    /// The individual items within the feed
109    /// * Atom (optional): Individual entries within the feed (e.g. a blog post)
110    /// * RSS 1+2 (optional): Individual items within the channel.
111    pub entries: Vec<Entry>,
112}
113
114impl Feed {
115    pub(crate) fn new(feed_type: FeedType) -> Self {
116        Feed {
117            feed_type,
118            id: "".into(),
119            title: None,
120            updated: None,
121            authors: Vec::new(),
122            description: None,
123            links: Vec::new(),
124            categories: Vec::new(),
125            contributors: Vec::new(),
126            generator: None,
127            icon: None,
128            language: None,
129            logo: None,
130            published: None,
131            rating: None,
132            rights: None,
133            ttl: None,
134            people: Vec::new(),
135            entries: Vec::new(),
136        }
137    }
138}
139
140impl Debug for Feed {
141    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
142        f.write_str(serde_json::to_string(self).unwrap().as_str())
143    }
144}
145
146#[cfg(test)]
147impl Feed {
148    pub fn author(mut self, person: Person) -> Self {
149        self.authors.push(person);
150        self
151    }
152
153    pub fn category(mut self, category: Category) -> Self {
154        self.categories.push(category);
155        self
156    }
157
158    pub fn contributor(mut self, person: Person) -> Self {
159        self.contributors.push(person);
160        self
161    }
162
163    pub fn description(mut self, description: Text) -> Self {
164        self.description = Some(description);
165        self
166    }
167
168    pub fn entry(mut self, entry: Entry) -> Self {
169        self.entries.push(entry);
170        self
171    }
172
173    pub fn generator(mut self, generator: Generator) -> Self {
174        self.generator = Some(generator);
175        self
176    }
177
178    pub fn icon(mut self, image: Image) -> Self {
179        self.icon = Some(image);
180        self
181    }
182
183    pub fn id(mut self, id: &str) -> Self {
184        self.id = id.to_string();
185        self
186    }
187
188    pub fn language(mut self, language: &str) -> Self {
189        self.language = Some(language.to_owned());
190        self
191    }
192
193    pub fn link(mut self, link: Link) -> Self {
194        self.links.push(link);
195        self
196    }
197
198    pub fn logo(mut self, image: Image) -> Self {
199        self.logo = Some(image);
200        self
201    }
202
203    pub fn published(mut self, pub_date: &str) -> Self {
204        self.published = parse_timestamp_lenient(pub_date);
205        self
206    }
207
208    pub fn rights(mut self, rights: Text) -> Self {
209        self.rights = Some(rights);
210        self
211    }
212
213    pub fn title(mut self, title: Text) -> Self {
214        self.title = Some(title);
215        self
216    }
217
218    pub fn ttl(mut self, ttl: u32) -> Self {
219        self.ttl = Some(ttl);
220        self
221    }
222
223    pub fn updated(mut self, updated: Option<DateTime<Utc>>) -> Self {
224        self.updated = updated;
225        self
226    }
227
228    pub fn updated_parsed(mut self, updated: &str) -> Self {
229        self.updated = parse_timestamp_lenient(updated);
230        self
231    }
232}
233
234/// Type of feed (RSS, Atom etc)
235#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
236pub enum FeedType {
237    Atom,
238    JSON,
239    RSS0,
240    RSS1,
241    RSS2,
242}
243
244/// An item within a feed
245#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
246pub struct Entry {
247    /// A unique identifier for this item with a feed. If not supplied it is initialised to a hash of the first link or a UUID if not available.
248    /// * Atom (required): Identifies the entry using a universally unique and permanent URI.
249    /// * RSS 2 (optional) "guid": A string that uniquely identifies the item.
250    /// * RSS 1: does not specify a unique ID as a separate item, but does suggest the URI should be "the same as the link" so we use a hash of the link if found
251    /// * JSON Feed: is unique for that item for that feed over time.
252    pub id: String,
253    /// Title of this item within the feed
254    /// * Atom, RSS 1(required): Contains a human-readable title for the entry.
255    /// * RSS 2 (optional): The title of the item.
256    /// * JSON Feed: The title of the item.
257    pub title: Option<Text>,
258    /// Time at which this item was last modified. If not provided in the source, or invalid, it is `None`.
259    /// * Atom (required): Indicates the last time the entry was modified in a significant way.
260    /// * RSS doesn't specify this field, so we copy it from the entry 'published' field for consistency.
261    /// * JSON Feed: the last modification date of this item
262    pub updated: Option<DateTime<Utc>>,
263
264    /// Authors of this item
265    /// * Atom (recommended): Collection of authors defined at the entry level.
266    /// * RSS 2 (optional): Email address of the author of the item.
267    /// * JSON Feed: the author of the item
268    pub authors: Vec<Person>,
269    /// The content of the item
270    /// * Atom (recommended): Contains or links to the complete content of the entry.
271    /// * RSS 2 (optional) "content:encoded": The HTML form of the content
272    /// * JSON Feed: the html content of the item, or the text content if no html is specified
273    pub content: Option<Content>,
274    /// Links associated with this item
275    /// * Atom (recommended): Identifies a related Web page.
276    /// * RSS 2 (optional): The URL of the item.
277    /// * RSS 1 (required): The item's URL.
278    /// * JSON Feed: the url and external URL for the item is the first items, then each subsequent attachment
279    pub links: Vec<Link>,
280    /// A short summary of the item
281    /// * Atom (recommended): Conveys a short summary, abstract, or excerpt of the entry.
282    /// * RSS 1 (optional): Populated from the RSS namespace 'description' field, or if not present, the Dublin Core namespace 'description' field.
283    /// * RSS 2 (optional): Populated from the RSS namespace 'description' field.
284    /// * JSON Feed: the summary for the item, or the text content if no summary is provided and both text and html content are specified
285    ///
286    /// Warning: Some feeds (especially RSS) use significant whitespace in this field even in cases where it should be considered HTML. Consider rendering this field in a way that preserves whitespace-based formatting such as a double-newline to separate paragraphs.
287    pub summary: Option<Text>,
288
289    /// Structured classification of the item
290    /// * Atom (optional): Specifies a category that the entry belongs to. A feed may have multiple category elements.
291    /// * RSS 2 (optional): Includes the item in one or more categories.
292    /// * JSON Feed: the supplied item tags
293    pub categories: Vec<Category>,
294    /// Atom (optional): Names one contributor to the entry. A feed may have multiple contributor elements.
295    pub contributors: Vec<Person>,
296    /// Time at which this item was first published
297    /// * Atom (optional): Contains the time of the initial creation or first availability of the entry.
298    /// * RSS 2 (optional) "pubDate": Indicates when the item was published.
299    /// * JSON Feed: the date at which the item was published
300    pub published: Option<DateTime<Utc>>,
301    /// Atom (optional): If an entry is copied from one feed into another feed, then this contains the source feed metadata.
302    pub source: Option<String>,
303    /// Atom (optional): Conveys information about rights, e.g. copyrights, held in and over the feed.
304    pub rights: Option<Text>,
305
306    /// Extension for MediaRSS - <https://www.rssboard.org/media-rss>
307    /// A MediaObject will be created in two cases:
308    /// 1) each "media:group" element encountered in the feed
309    /// 2) a default for any other "media:*" elements found at the item level
310    ///
311    /// See the Atom tests for YouTube and newscred for examples
312    pub media: Vec<MediaObject>,
313
314    /// Atom (optional): The language specified on the item
315    pub language: Option<String>,
316    /// Atom (optional): The base url specified on the item to resolve any relative
317    /// references found within the scope on the item
318    pub base: Option<String>,
319}
320
321impl Default for Entry {
322    fn default() -> Self {
323        Entry {
324            id: "".into(),
325            title: None,
326            updated: None,
327            authors: Vec::new(),
328            content: None,
329            links: Vec::new(),
330            summary: None,
331            categories: Vec::new(),
332            contributors: Vec::new(),
333            published: None,
334            source: None,
335            rights: None,
336            media: Vec::new(),
337            language: None,
338            base: None,
339        }
340    }
341}
342
343#[cfg(test)]
344impl Entry {
345    pub fn author(mut self, person: Person) -> Self {
346        self.authors.push(person);
347        self
348    }
349
350    pub fn category(mut self, category: Category) -> Self {
351        self.categories.push(category);
352        self
353    }
354
355    pub fn content(mut self, content: Content) -> Self {
356        self.content = Some(content);
357        self
358    }
359
360    pub fn contributor(mut self, person: Person) -> Self {
361        self.contributors.push(person);
362        self
363    }
364
365    pub fn id(mut self, id: &str) -> Self {
366        self.id = id.trim().to_string();
367        self
368    }
369
370    pub fn link(mut self, link: Link) -> Self {
371        self.links.push(link);
372        self
373    }
374
375    pub fn published(mut self, published: &str) -> Self {
376        self.published = parse_timestamp_lenient(published);
377        self
378    }
379
380    pub fn rights(mut self, rights: Text) -> Self {
381        self.rights = Some(rights);
382        self
383    }
384
385    pub fn summary(mut self, summary: Text) -> Self {
386        self.summary = Some(summary);
387        self
388    }
389
390    pub fn title(mut self, title: Text) -> Self {
391        self.title = Some(title);
392        self
393    }
394
395    pub fn updated(mut self, updated: Option<DateTime<Utc>>) -> Self {
396        self.updated = updated;
397        self
398    }
399
400    pub fn updated_parsed(mut self, updated: &str) -> Self {
401        self.updated = parse_timestamp_lenient(updated);
402        self
403    }
404
405    pub fn media(mut self, media: MediaObject) -> Self {
406        self.media.push(media);
407        self
408    }
409
410    pub fn language(mut self, language: &str) -> Self {
411        self.language = Some(language.to_owned());
412        self
413    }
414
415    pub fn base(mut self, url: &str) -> Self {
416        self.base = Some(url.to_owned());
417        self
418    }
419}
420
421/// Represents the category of a feed or entry
422///
423/// [Atom spec]: http://www.atomenabled.org/developers/syndication/#category
424/// [RSS 2 spec]: https://validator.w3.org/feed/docs/rss2.html#ltcategorygtSubelementOfLtitemgt
425#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
426pub struct Category {
427    /// The category as a human readable string
428    /// * Atom (required): Identifies the category.
429    /// * RSS 2: The value of the element is a forward-slash-separated string that identifies a hierarchic location in the indicated taxonomy. Processors may establish conventions for the interpretation of categories.
430    /// * JSON Feed: the value of the tag
431    pub term: String,
432    /// Atom (optional): Identifies the categorization scheme via a URI.
433    pub scheme: Option<String>,
434    /// Atom (optional): Provides a human-readable label for display.
435    pub label: Option<String>,
436    /// Sub-categories (typically from the iTunes namespace i.e. <https://help.apple.com/itc/podcasts_connect/#/itcb54353390>)
437    pub subcategories: Vec<Category>,
438}
439
440impl Category {
441    pub fn new(term: &str) -> Category {
442        Category {
443            term: term.trim().into(),
444            scheme: None,
445            label: None,
446            subcategories: Vec::new(),
447        }
448    }
449}
450
451#[cfg(test)]
452impl Category {
453    pub fn label(mut self, label: &str) -> Self {
454        self.label = Some(label.to_owned());
455        self
456    }
457
458    pub fn scheme(mut self, scheme: &str) -> Self {
459        self.scheme = Some(scheme.to_owned());
460        self
461    }
462}
463
464/// Content, or link to the content, for a given entry.
465///
466/// [Atom spec]: http://www.atomenabled.org/developers/syndication/#contentElement
467/// [RSS 2.0]: https://validator.w3.org/feed/docs/rss2.html#ltenclosuregtSubelementOfLtitemgt
468#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
469pub struct Content {
470    /// Atom
471    /// * If the type attribute ends in +xml or /xml, then an xml document of this type is contained inline.
472    /// * If the type attribute starts with text, then an escaped document of this type is contained inline.
473    /// * Otherwise a base64 encoded document of the indicated media type is contained inline.
474    pub body: Option<String>,
475    /// Type of content
476    /// * Atom: The type attribute is either text, html, xhtml, in which case the content element is defined identically to other text constructs.
477    /// * RSS 2: Type says what its type is, a standard MIME type
478    pub content_type: MediaTypeBuf,
479    /// RSS 2.0: Length of the content in bytes
480    pub length: Option<u64>,
481    /// Source of the content
482    /// * Atom: If the src attribute is present, it represents the URI of where the content can be found. The type attribute, if present, is the media type of the content.
483    /// * RSS 2.0: where the enclosure is located
484    pub src: Option<Link>,
485}
486
487impl Default for Content {
488    fn default() -> Content {
489        Content {
490            body: None,
491            content_type: MediaTypeBuf::new(names::TEXT, names::PLAIN),
492            length: None,
493            src: None,
494        }
495    }
496}
497
498impl Content {
499    pub fn sanitize(&mut self) {
500        // We're dealing with a broader variety of possible content types than
501        // in Text, since the possibility exists that we'll be dealing with a base64-encode
502        // image or the like, so we'll target a correspondingly tighter set: text/html
503        // and application/xhtml+xml.
504        #[cfg(feature = "sanitize")]
505        {
506            let content_type = self.content_type.as_str();
507            if content_type == "text/html" || content_type == "application/xhtml+xml" {
508                if let Some(body) = &self.body {
509                    self.body = Some(ammonia::clean(body.as_str()));
510                }
511            }
512        }
513    }
514}
515
516#[cfg(test)]
517impl Content {
518    pub fn body(mut self, body: &str) -> Self {
519        self.body = Some(body.to_owned());
520        self
521    }
522
523    pub fn content_type(mut self, content_type: &str) -> Self {
524        self.content_type = content_type.parse::<MediaTypeBuf>().unwrap();
525        self
526    }
527
528    pub fn length(mut self, length: u64) -> Self {
529        self.length = Some(length);
530        self
531    }
532
533    pub fn src(mut self, url: &str) -> Self {
534        self.src = Some(Link::new(url, None));
535        self
536    }
537}
538
539/// Information on the tools used to generate the feed
540///
541/// Atom: Identifies the software used to generate the feed, for debugging and other purposes.
542#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
543pub struct Generator {
544    /// Atom: Additional data
545    /// RSS 2: A string indicating the program used to generate the channel.
546    pub content: String,
547    /// Atom: Link to the tool
548    pub uri: Option<String>,
549    /// Atom: Tool version
550    pub version: Option<String>,
551}
552
553impl Generator {
554    pub(crate) fn new(content: &str) -> Generator {
555        Generator {
556            uri: None,
557            version: None,
558            content: content.trim().into(),
559        }
560    }
561}
562
563#[cfg(test)]
564impl Generator {
565    pub fn uri(mut self, uri: &str) -> Self {
566        self.uri = Some(uri.to_owned());
567        self
568    }
569
570    pub fn version(mut self, version: &str) -> Self {
571        self.version = Some(version.to_owned());
572        self
573    }
574}
575
576/// Represents a a link to an image.
577///
578/// [Atom spec]:  http://www.atomenabled.org/developers/syndication/#optionalFeedElements
579/// [RSS 2 spec]: https://validator.w3.org/feed/docs/rss2.html#ltimagegtSubelementOfLtchannelgt
580/// [RSS 1 spec]: https://validator.w3.org/feed/docs/rss1.html#s5.4
581#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
582pub struct Image {
583    /// Link to the image
584    /// * Atom: The URL to an image or logo
585    /// * RSS 1 + 2: the URL of a GIF, JPEG or PNG image that represents the channel.
586    pub uri: String,
587    /// RSS 1 + 2: describes the image, it's used in the ALT attribute of the HTML <img> tag when the channel is rendered in HTML.
588    pub title: Option<String>,
589    /// RSS 1 + 2: the URL of the site, when the channel is rendered, the image is a link to the site.
590    pub link: Option<Link>,
591
592    /// RSS 2 (optional): width of the image
593    pub width: Option<u32>,
594    /// RSS 2 (optional): height of the image
595    pub height: Option<u32>,
596    /// RSS 2 (optional): contains text that is included in the TITLE attribute of the link formed around the image in the HTML rendering.
597    pub description: Option<String>,
598}
599
600impl Image {
601    pub(crate) fn new(uri: String) -> Image {
602        Image {
603            uri,
604            title: None,
605            link: None,
606            width: None,
607            height: None,
608            description: None,
609        }
610    }
611}
612
613#[cfg(test)]
614impl Image {
615    pub fn description(mut self, description: &str) -> Self {
616        self.description = Some(description.to_owned());
617        self
618    }
619
620    pub fn height(mut self, height: u32) -> Self {
621        self.height = Some(height);
622        self
623    }
624
625    pub fn link(mut self, link: &str) -> Self {
626        self.link = Some(Link::new(link, None));
627        self
628    }
629
630    pub fn title(mut self, title: &str) -> Self {
631        self.title = Some(title.to_owned());
632        self
633    }
634
635    pub fn width(mut self, width: u32) -> Self {
636        self.width = Some(width);
637        self
638    }
639}
640
641/// Represents a link to an associated resource for the feed or entry.
642///
643/// [Atom spec]: http://www.atomenabled.org/developers/syndication/#link
644#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
645pub struct Link {
646    /// Link to additional content
647    /// * Atom: The URI of the referenced resource (typically a Web page).
648    /// * RSS 2: The URL to the HTML website corresponding to the channel or item.
649    /// * JSON Feed: the URI to the attachment, feed etc
650    pub href: String,
651
652    /// Information on the target of this link if available
653    pub target: Option<LinkTarget>,
654
655    /// Atom: a single link relationship type from the enumerated set.
656    pub rel: Option<String>,
657    /// Indicates the media type of the resource.
658    pub media_type: Option<String>,
659    /// Indicates the language of the referenced resource.
660    pub href_lang: Option<String>,
661    /// Human-readable information about the link, typically for display purposes.
662    pub title: Option<String>,
663    /// The length of the resource, in bytes.
664    pub length: Option<u64>,
665}
666
667impl Link {
668    pub(crate) fn new<S: AsRef<str>>(href: S, base: Option<&Url>) -> Link {
669        let href = match util::parse_uri(href.as_ref(), base) {
670            Some(uri) => uri.to_string(),
671            None => href.as_ref().to_string(),
672        }
673        .trim()
674        .to_string();
675
676        Link {
677            href,
678            target: None,
679            rel: None,
680            media_type: None,
681            href_lang: None,
682            title: None,
683            length: None,
684        }
685    }
686}
687
688#[cfg(test)]
689impl Link {
690    pub fn href_lang(mut self, lang: &str) -> Self {
691        self.href_lang = Some(lang.to_owned());
692        self
693    }
694
695    pub fn length(mut self, length: u64) -> Self {
696        self.length = Some(length);
697        self
698    }
699
700    pub fn media_type(mut self, media_type: &str) -> Self {
701        self.media_type = Some(media_type.to_owned());
702        self
703    }
704
705    pub fn rel(mut self, rel: &str) -> Self {
706        self.rel = Some(rel.to_owned());
707        self
708    }
709
710    pub fn target(mut self, target: LinkTarget) -> Self {
711        self.target = Some(target);
712        self
713    }
714
715    pub fn title(mut self, title: &str) -> Self {
716        self.title = Some(title.to_owned());
717        self
718    }
719}
720
721/// Type of resource to which this link points
722#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
723pub enum LinkTarget {
724    /// A link to comments, likely an HTML anchor to the corresponding section of a page
725    /// [RSS 2.0 standard]: https://www.rssboard.org/rss-specification#ltcommentsgtSubelementOfLtitemgt
726    Comments,
727    /// Link to the comments RSS feed
728    /// [Comment API NS]: https://www.rssboard.org/comment-api
729    ///
730    CommentsFeed,
731}
732
733/// The top-level representation of a media object
734/// i.e. combines "media:*" elements from the RSS Media spec such as those under a media:group
735#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
736pub struct MediaObject {
737    // Final post-processed source of this media object
738    pub(crate) source: Option<MediaObjectSource>,
739
740    /// Title of the object (from the media:title element)
741    pub title: Option<Text>,
742    /// Collection of the media content elements
743    pub content: Vec<MediaContent>,
744    /// Duration of the object
745    pub duration: Option<Duration>,
746    /// Representative images for the object (from media:thumbnail elements)
747    pub thumbnails: Vec<MediaThumbnail>,
748    /// A text transcript, closed captioning or lyrics of the media content.
749    pub texts: Vec<MediaText>,
750    /// Short description of the media object (from the media:description element)
751    pub description: Option<Text>,
752    /// Community info (from the media:community element)
753    pub community: Option<MediaCommunity>,
754    /// Credits
755    pub credits: Vec<MediaCredit>,
756
757    /// Podcast: People associated with this episode
758    pub people: Vec<PodcastPerson>,
759    /// iTunes + Podcast (optional): Season number
760    pub season: Option<Season>,
761    /// iTunes + Podcast (optional): Episode number
762    pub episode: Option<Episode>,
763    /// Podcast: Available transcripts
764    pub transcripts: Vec<Transcript>,
765}
766
767impl MediaObject {
768    // Checks if this object has been populated with any content (e.g. iTunes, Podcast etc.)
769    pub(crate) fn is_not_empty(&self) -> bool {
770        self.title.is_some() || self.description.is_some() || !self.content.is_empty() || !self.texts.is_empty() || !self.transcripts.is_empty()
771    }
772
773    // Creates a new instance with the nominated source
774    pub(crate) fn new(source: MediaObjectSource) -> Self {
775        MediaObject {
776            source: Some(source),
777            ..Default::default()
778        }
779    }
780}
781
782#[cfg(test)]
783impl MediaObject {
784    pub fn community(mut self, community: MediaCommunity) -> Self {
785        self.community = Some(community);
786        self
787    }
788
789    pub fn content(mut self, content: MediaContent) -> Self {
790        self.content.push(content);
791        self
792    }
793
794    pub fn credit(mut self, entity: &str) -> Self {
795        self.credits.push(MediaCredit::new(entity.to_string()));
796        self
797    }
798
799    pub fn description(mut self, description: &str) -> Self {
800        self.description = Some(Text::new(description.to_string()));
801        self
802    }
803
804    pub fn duration(mut self, duration: Duration) -> Self {
805        self.duration = Some(duration);
806        self
807    }
808
809    pub fn text(mut self, text: MediaText) -> Self {
810        self.texts.push(text);
811        self
812    }
813
814    pub fn thumbnail(mut self, thumbnail: MediaThumbnail) -> Self {
815        self.thumbnails.push(thumbnail);
816        self
817    }
818
819    pub fn title(mut self, title: &str) -> Self {
820        self.title = Some(Text::new(title.to_string()));
821        self
822    }
823}
824
825#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
826pub(crate) enum MediaObjectSource {
827    RSS,
828    MediaRSS,
829    ITunes,
830    Podcast,
831}
832
833/// Represents a "media:community" item from the RSS Media spec
834#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
835pub struct MediaCommunity {
836    /// Star rating
837    pub stars_avg: Option<f64>,
838    pub stars_count: Option<u64>,
839    pub stars_min: Option<u64>,
840    pub stars_max: Option<u64>,
841
842    /// Statistics on engagement
843    pub stats_views: Option<u64>,
844    pub stats_favorites: Option<u64>,
845}
846
847impl MediaCommunity {
848    pub(crate) fn new() -> MediaCommunity {
849        MediaCommunity {
850            stars_avg: None,
851            stars_count: None,
852            stars_min: None,
853            stars_max: None,
854            stats_views: None,
855            stats_favorites: None,
856        }
857    }
858}
859
860#[cfg(test)]
861impl MediaCommunity {
862    pub fn star_rating(mut self, count: u64, average: f64, min: u64, max: u64) -> Self {
863        self.stars_count = Some(count);
864        self.stars_avg = Some(average);
865        self.stars_min = Some(min);
866        self.stars_max = Some(max);
867        self
868    }
869
870    pub fn statistics(mut self, views: Option<u64>, favorites: Option<u64>) -> Self {
871        self.stats_views = views;
872        self.stats_favorites = favorites;
873        self
874    }
875}
876
877/// Represents a "media:content" item from the RSS Media spec
878#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
879pub struct MediaContent {
880    /// The direct URL
881    pub url: Option<Url>,
882    /// Standard MIME type
883    pub content_type: Option<MediaTypeBuf>,
884    /// Height and width
885    pub height: Option<u32>,
886    pub width: Option<u32>,
887    /// Duration the media plays
888    pub duration: Option<Duration>,
889    /// Size of media in bytes
890    pub size: Option<u64>,
891    /// Rating
892    pub rating: Option<MediaRating>,
893}
894
895#[cfg(test)]
896impl MediaContent {
897    pub fn content_type(mut self, content_type: &str) -> Self {
898        self.content_type = Some(content_type.parse::<MediaTypeBuf>().unwrap());
899        self
900    }
901
902    pub fn height(mut self, height: u32) -> Self {
903        self.height = Some(height);
904        self
905    }
906
907    pub fn url(mut self, url: &str) -> Self {
908        self.url = Some(Url::parse(url).unwrap());
909        self
910    }
911
912    pub fn width(mut self, width: u32) -> Self {
913        self.width = Some(width);
914        self
915    }
916
917    pub fn duration(mut self, duration: Duration) -> Self {
918        self.duration = Some(duration);
919        self
920    }
921
922    pub fn size(mut self, size: u64) -> Self {
923        self.size = Some(size);
924        self
925    }
926
927    pub fn rating(mut self, rating: MediaRating) -> Self {
928        self.rating = Some(rating);
929        self
930    }
931}
932
933impl MediaContent {
934    pub(crate) fn new() -> MediaContent {
935        MediaContent {
936            url: None,
937            content_type: None,
938            height: None,
939            width: None,
940            duration: None,
941            size: None,
942            rating: None,
943        }
944    }
945}
946
947/// Represents a "media:credit" item from the RSS Media spec
948#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
949pub struct MediaCredit {
950    /// The entity being credited
951    pub entity: String,
952}
953
954impl MediaCredit {
955    pub(crate) fn new(entity: String) -> MediaCredit {
956        MediaCredit { entity }
957    }
958}
959
960/// Rating of the feed, item or media within the content
961#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
962pub struct MediaRating {
963    // The scheme (defaults to "simple" per the spec)
964    pub urn: String,
965    // The rating text
966    pub value: String,
967}
968
969impl MediaRating {
970    pub(crate) fn new(value: String) -> MediaRating {
971        MediaRating { urn: "simple".into(), value }
972    }
973
974    pub fn urn(mut self, urn: &str) -> Self {
975        self.urn = urn.to_string();
976        self
977    }
978}
979
980/// Represents a "media:text" item from the RSS Media spec
981#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
982pub struct MediaText {
983    /// The text
984    pub text: Text,
985    /// The start time offset that the text starts being relevant to the media object.
986    pub start_time: Option<Duration>,
987    /// The end time that the text is relevant. If this attribute is not provided, and a start time is used, it is expected that the end time is either the end of the clip or the start of the next <media:text> element.
988    pub end_time: Option<Duration>,
989}
990
991impl MediaText {
992    pub(crate) fn new(text: Text) -> MediaText {
993        MediaText {
994            text,
995            start_time: None,
996            end_time: None,
997        }
998    }
999}
1000
1001/// Represents a "media:thumbnail" item from the RSS Media spec
1002#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1003pub struct MediaThumbnail {
1004    /// The thumbnail image
1005    pub image: Image,
1006    /// The time this thumbnail represents
1007    pub time: Option<Duration>,
1008}
1009
1010impl MediaThumbnail {
1011    pub(crate) fn new(image: Image) -> MediaThumbnail {
1012        MediaThumbnail { image, time: None }
1013    }
1014}
1015
1016/// Represents an author, contributor etc.
1017///
1018/// [Atom spec]: http://www.atomenabled.org/developers/syndication/#person
1019#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1020pub struct Person {
1021    /// Atom: human-readable name for the person.
1022    /// JSON Feed: human-readable name for the person.
1023    pub name: Option<String>,
1024    /// Atom: home page for the person.
1025    /// JSON Feed: link to media (Twitter etc) for the person
1026    pub uri: Option<String>,
1027    /// Atom: An email address for the person.
1028    pub email: Option<String>,
1029    /// Various feed formats define the concept of roles at either element or attribute level with a variety of formats
1030    pub role: Option<String>,
1031}
1032
1033impl Person {
1034    pub(crate) fn empty() -> Person {
1035        Person {
1036            name: None,
1037            uri: None,
1038            email: None,
1039            role: None,
1040        }
1041    }
1042
1043    pub(crate) fn parse(raw: &str) -> Person {
1044        util::parse_person_name_email(raw)
1045    }
1046
1047    pub(crate) fn email(mut self, email: &str) -> Self {
1048        self.email = Some(email.trim().into());
1049        self
1050    }
1051
1052    #[allow(dead_code)]
1053    pub(crate) fn name(mut self, name: &str) -> Self {
1054        self.name = Some(name.trim().into());
1055        self
1056    }
1057
1058    pub(crate) fn role(mut self, role: &str) -> Self {
1059        self.role = Some(role.trim().into());
1060        self
1061    }
1062}
1063
1064#[cfg(test)]
1065impl Person {
1066    pub fn uri(mut self, uri: &str) -> Self {
1067        self.uri = Some(uri.to_owned());
1068        self
1069    }
1070}
1071
1072/// Represents a "podcast:person" from the [Podcast spec].
1073///
1074/// [Podcast spec]: https://podcastindex.org/namespace/1.0#person
1075#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1076pub struct PodcastPerson {
1077    pub name: String,
1078    #[serde(deserialize_with = "podcast_enum")]
1079    pub role: Role,
1080    #[serde(deserialize_with = "podcast_enum")]
1081    pub group: Group,
1082    pub img: Option<Url>,
1083    pub href: Option<Url>,
1084}
1085
1086/// Represents a [`PodcastPerson`](PodcastPerson)'s role.
1087///
1088/// See the [taxonomy](https://github.com/Podcastindex-org/podcast-namespace/blob/main/taxonomy.json)
1089/// for a list of roles and their associated groups.
1090#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1091pub enum Role {
1092    Director,
1093    #[serde(rename = "Assistant Director")]
1094    AssistantDirector,
1095    #[serde(rename = "Executive Producer")]
1096    ExecutiveProducer,
1097    #[serde(rename = "Senior Producer")]
1098    SeniorProducer,
1099    Producer,
1100    #[serde(rename = "Associate Producer")]
1101    AssociateProducer,
1102    #[serde(rename = "Development Producer")]
1103    DevelopmentProducer,
1104    #[serde(rename = "Creative Director")]
1105    CreativeDirector,
1106    Host,
1107    #[serde(rename = "Co-Host")]
1108    CoHost,
1109    #[serde(rename = "Guest Host")]
1110    GuestHost,
1111    Guest,
1112    #[serde(rename = "Voice Actor")]
1113    VoiceActor,
1114    Narrator,
1115    Announcer,
1116    Reporter,
1117    Author,
1118    #[serde(rename = "Editorial Director")]
1119    EditorialDirector,
1120    #[serde(rename = "Co-Writer")]
1121    CoWriter,
1122    Writer,
1123    Songwriter,
1124    #[serde(rename = "Guest Writer")]
1125    GuestWriter,
1126    #[serde(rename = "Story Editor")]
1127    StoryEditor,
1128    #[serde(rename = "Managing Editor")]
1129    ManagingEditor,
1130    #[serde(rename = "Script Editor")]
1131    ScriptEditor,
1132    #[serde(rename = "Script Coordinator")]
1133    ScriptCoordinator,
1134    Researcher,
1135    #[serde(rename = "Fact Checker")]
1136    FactChecker,
1137    Translator,
1138    Transcriber,
1139    Logger,
1140    #[serde(rename = "Studio Coordinator")]
1141    StudioCoordinator,
1142    #[serde(rename = "Technical Director")]
1143    TechnicalDirector,
1144    #[serde(rename = "Technical Manager")]
1145    TechnicalManager,
1146    #[serde(rename = "Audio Engineer")]
1147    AudioEngineer,
1148    #[serde(rename = "Remote Recording Engineer")]
1149    RemoteRecordingEngineer,
1150    #[serde(rename = "Post Production Engineer")]
1151    PostProductionEngineer,
1152    #[serde(rename = "Audio Editor")]
1153    AudioEditor,
1154    #[serde(rename = "Sound Designer")]
1155    SoundDesigner,
1156    #[serde(rename = "Foley Artist")]
1157    FoleyArtist,
1158    Composer,
1159    #[serde(rename = "Theme Music")]
1160    ThemeMusic,
1161    #[serde(rename = "Music Production")]
1162    MusicProduction,
1163    #[serde(rename = "Music Contributor")]
1164    MusicContributor,
1165    #[serde(rename = "Production Coordinator")]
1166    ProductionCoordinator,
1167    #[serde(rename = "Booking Coordinator")]
1168    BookingCoordinator,
1169    #[serde(rename = "Production Assistant")]
1170    ProductionAssistant,
1171    #[serde(rename = "Content Manager")]
1172    ContentManager,
1173    #[serde(rename = "Marketing Manager")]
1174    MarketingManager,
1175    #[serde(rename = "Sales Representative")]
1176    SalesRepresentative,
1177    #[serde(rename = "Sales Manager")]
1178    SalesManager,
1179    #[serde(rename = "Graphic Designer")]
1180    GraphicDesigner,
1181    #[serde(rename = "Cover Art Designer")]
1182    CoverArtDesigner,
1183    #[serde(rename = "Social Media Manager")]
1184    SocialMediaManager,
1185    Consultant,
1186    Intern,
1187    #[serde(rename = "Camera Operator")]
1188    CameraOperator,
1189    #[serde(rename = "Lighting Designer")]
1190    LightingDesigner,
1191    #[serde(rename = "Camera Grip")]
1192    CameraGrip,
1193    #[serde(rename = "Assistant Camera")]
1194    AssistantCamera,
1195    Editor,
1196    #[serde(rename = "Assistant Editor")]
1197    AssistantEditor,
1198}
1199
1200impl Role {
1201    pub const fn default_group(&self) -> Group {
1202        match self {
1203            Self::Director => Group::CreativeDirection,
1204            Self::AssistantDirector => Group::CreativeDirection,
1205            Self::ExecutiveProducer => Group::CreativeDirection,
1206            Self::SeniorProducer => Group::CreativeDirection,
1207            Self::Producer => Group::CreativeDirection,
1208            Self::AssociateProducer => Group::CreativeDirection,
1209            Self::DevelopmentProducer => Group::CreativeDirection,
1210            Self::CreativeDirector => Group::CreativeDirection,
1211            Self::Host => Group::Cast,
1212            Self::CoHost => Group::Cast,
1213            Self::GuestHost => Group::Cast,
1214            Self::Guest => Group::Cast,
1215            Self::VoiceActor => Group::Cast,
1216            Self::Narrator => Group::Cast,
1217            Self::Announcer => Group::Cast,
1218            Self::Reporter => Group::Cast,
1219            Self::Author => Group::Writing,
1220            Self::EditorialDirector => Group::Writing,
1221            Self::CoWriter => Group::Writing,
1222            Self::Writer => Group::Writing,
1223            Self::Songwriter => Group::Writing,
1224            Self::GuestWriter => Group::Writing,
1225            Self::StoryEditor => Group::Writing,
1226            Self::ManagingEditor => Group::Writing,
1227            Self::ScriptEditor => Group::Writing,
1228            Self::ScriptCoordinator => Group::Writing,
1229            Self::Researcher => Group::Writing,
1230            Self::Editor => Group::Writing,
1231            Self::FactChecker => Group::Writing,
1232            Self::Translator => Group::Writing,
1233            Self::Transcriber => Group::Writing,
1234            Self::Logger => Group::Writing,
1235            Self::StudioCoordinator => Group::AudioProduction,
1236            Self::TechnicalDirector => Group::AudioProduction,
1237            Self::TechnicalManager => Group::AudioProduction,
1238            Self::AudioEngineer => Group::AudioProduction,
1239            Self::RemoteRecordingEngineer => Group::AudioProduction,
1240            Self::PostProductionEngineer => Group::AudioProduction,
1241            Self::AudioEditor => Group::AudioPostProduction,
1242            Self::SoundDesigner => Group::AudioPostProduction,
1243            Self::FoleyArtist => Group::AudioPostProduction,
1244            Self::Composer => Group::AudioPostProduction,
1245            Self::ThemeMusic => Group::AudioPostProduction,
1246            Self::MusicProduction => Group::AudioPostProduction,
1247            Self::MusicContributor => Group::AudioPostProduction,
1248            Self::ProductionCoordinator => Group::Administration,
1249            Self::BookingCoordinator => Group::Administration,
1250            Self::ProductionAssistant => Group::Administration,
1251            Self::ContentManager => Group::Administration,
1252            Self::MarketingManager => Group::Administration,
1253            Self::SalesRepresentative => Group::Administration,
1254            Self::SalesManager => Group::Administration,
1255            Self::GraphicDesigner => Group::Visuals,
1256            Self::CoverArtDesigner => Group::Visuals,
1257            Self::SocialMediaManager => Group::Community,
1258            Self::Consultant => Group::Misc,
1259            Self::Intern => Group::Misc,
1260            Self::CameraOperator => Group::VideoProduction,
1261            Self::LightingDesigner => Group::VideoProduction,
1262            Self::CameraGrip => Group::VideoProduction,
1263            Self::AssistantCamera => Group::VideoProduction,
1264            Self::AssistantEditor => Group::VideoPostProduction,
1265        }
1266    }
1267
1268    pub(crate) fn parse(position: u64, s: &str) -> Result<Self, ParseFeedError> {
1269        match normalise_case(s).as_str() {
1270            "Director" => Ok(Role::Director),
1271            "Assistant Director" => Ok(Role::AssistantDirector),
1272            "Executive Producer" => Ok(Role::ExecutiveProducer),
1273            "Senior Producer" => Ok(Role::SeniorProducer),
1274            "Producer" => Ok(Role::Producer),
1275            "Associate Producer" => Ok(Role::AssociateProducer),
1276            "Development Producer" => Ok(Role::DevelopmentProducer),
1277            "Creative Director" => Ok(Role::CreativeDirector),
1278            "Host" => Ok(Role::Host),
1279            "Co-Host" => Ok(Role::CoHost),
1280            "Guest Host" => Ok(Role::GuestHost),
1281            "Guest" => Ok(Role::Guest),
1282            "Voice Actor" => Ok(Role::VoiceActor),
1283            "Narrator" => Ok(Role::Narrator),
1284            "Announcer" => Ok(Role::Announcer),
1285            "Reporter" => Ok(Role::Reporter),
1286            "Author" => Ok(Role::Author),
1287            "Editorial Director" => Ok(Role::EditorialDirector),
1288            "Co-Writer" => Ok(Role::CoWriter),
1289            "Writer" => Ok(Role::Writer),
1290            "Songwriter" => Ok(Role::Songwriter),
1291            "Guest Writer" => Ok(Role::GuestWriter),
1292            "Story Editor" => Ok(Role::StoryEditor),
1293            "Managing Editor" => Ok(Role::ManagingEditor),
1294            "Script Editor" => Ok(Role::ScriptEditor),
1295            "Script Coordinator" => Ok(Role::ScriptCoordinator),
1296            "Researcher" => Ok(Role::Researcher),
1297            "Fact Checker" => Ok(Role::FactChecker),
1298            "Translator" => Ok(Role::Translator),
1299            "Transcriber" => Ok(Role::Transcriber),
1300            "Logger" => Ok(Role::Logger),
1301            "Studio Coordinator" => Ok(Role::StudioCoordinator),
1302            "Technical Director" => Ok(Role::TechnicalDirector),
1303            "Technical Manager" => Ok(Role::TechnicalManager),
1304            "Audio Engineer" => Ok(Role::AudioEngineer),
1305            "Remote Recording Engineer" => Ok(Role::RemoteRecordingEngineer),
1306            "Post Production Engineer" => Ok(Role::PostProductionEngineer),
1307            "Audio Editor" => Ok(Role::AudioEditor),
1308            "Sound Designer" => Ok(Role::SoundDesigner),
1309            "Foley Artist" => Ok(Role::FoleyArtist),
1310            "Composer" => Ok(Role::Composer),
1311            "Theme Music" => Ok(Role::ThemeMusic),
1312            "Music Production" => Ok(Role::MusicProduction),
1313            "Music Contributor" => Ok(Role::MusicContributor),
1314            "Production Coordinator" => Ok(Role::ProductionCoordinator),
1315            "Booking Coordinator" => Ok(Role::BookingCoordinator),
1316            "Production Assistant" => Ok(Role::ProductionAssistant),
1317            "Content Manager" => Ok(Role::ContentManager),
1318            "Marketing Manager" => Ok(Role::MarketingManager),
1319            "Sales Representative" => Ok(Role::SalesRepresentative),
1320            "Sales Manager" => Ok(Role::SalesManager),
1321            "Graphic Designer" => Ok(Role::GraphicDesigner),
1322            "Cover Art Designer" => Ok(Role::CoverArtDesigner),
1323            "Social Media Manager" => Ok(Role::SocialMediaManager),
1324            "Consultant" => Ok(Role::Consultant),
1325            "Intern" => Ok(Role::Intern),
1326            "Camera Operator" => Ok(Role::CameraOperator),
1327            "Lighting Designer" => Ok(Role::LightingDesigner),
1328            "Camera Grip" => Ok(Role::CameraGrip),
1329            "Assistant Camera" => Ok(Role::AssistantCamera),
1330            "Editor" => Ok(Role::Editor),
1331            "Assistant Editor" => Ok(Role::AssistantEditor),
1332            _ => Err(ParseFeedError::ParseError(ParseErrorKind::UnknownEnumVariant {
1333                position,
1334                reference: s.to_string(),
1335            })),
1336        }
1337    }
1338}
1339
1340/// Represents a [`PodcastPerson`](PodcastPerson)'s group.
1341///
1342/// See the [taxonomy](https://github.com/Podcastindex-org/podcast-namespace/blob/main/taxonomy.json)
1343/// for a list of groups.
1344#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1345pub enum Group {
1346    Administration,
1347    #[serde(rename = "Audio Post-Production")]
1348    AudioPostProduction,
1349    #[serde(rename = "Audio Production")]
1350    AudioProduction,
1351    #[default]
1352    Cast,
1353    Community,
1354    #[serde(rename = "Creative Direction")]
1355    CreativeDirection,
1356    #[serde(rename = "Misc.")]
1357    Misc,
1358    #[serde(rename = "Video Post-Production")]
1359    VideoPostProduction,
1360    #[serde(rename = "Video Production")]
1361    VideoProduction,
1362    Visuals,
1363    Writing,
1364}
1365
1366impl Group {
1367    pub(crate) fn parse(position: u64, s: &str) -> Result<Self, ParseFeedError> {
1368        match normalise_case(s).as_str() {
1369            "Administration" => Ok(Group::Administration),
1370            "Audio Post-Production" => Ok(Group::AudioPostProduction),
1371            "Audio Production" => Ok(Group::AudioProduction),
1372            "Cast" => Ok(Group::Cast),
1373            "Community" => Ok(Group::Community),
1374            "Creative Direction" => Ok(Group::CreativeDirection),
1375            "Misc" => Ok(Group::Misc),
1376            "Video Post-Production" => Ok(Group::VideoPostProduction),
1377            "Video Production" => Ok(Group::VideoProduction),
1378            "Visuals" => Ok(Group::Visuals),
1379            "Writing" => Ok(Group::Writing),
1380            _ => Err(ParseFeedError::ParseError(ParseErrorKind::UnknownEnumVariant {
1381                position,
1382                reference: s.to_string(),
1383            })),
1384        }
1385    }
1386}
1387
1388/// Represents a podcast episode's transcript.
1389///
1390/// See [the spec](https://podcastindex.org/namespace/1.0#transcript).
1391#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1392pub struct Transcript {
1393    pub url: Url,
1394    #[serde(rename = "type")]
1395    pub content_type: MediaTypeBuf,
1396    pub language: Option<String>,
1397    pub rel: Option<String>,
1398}
1399
1400/// Represents a podcast episode's season number. Used in tandem with the [`Episode`](Episode) number.
1401///
1402/// See [the spec](https://podcastindex.org/namespace/1.0#season).
1403#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1404pub struct Season {
1405    pub name: Option<String>,
1406    pub number: u32,
1407}
1408
1409/// Represents a podcast episode's episode number. Used in tandem with the [`Season`](Season) number.
1410///
1411/// See [the spec](https://podcastindex.org/namespace/1.0#episode).
1412#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
1413pub struct Episode {
1414    pub display: Option<String>,
1415    pub number: f32,
1416}
1417
1418/// Textual content, or link to the content, for a given entry.
1419#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1420pub struct Text {
1421    pub content_type: MediaTypeBuf,
1422    pub src: Option<String>,
1423    pub content: String,
1424}
1425
1426impl Text {
1427    pub(crate) fn new(content: String) -> Text {
1428        Text {
1429            content_type: MediaTypeBuf::new(names::TEXT, names::PLAIN),
1430            src: None,
1431            content: content.trim().to_string(),
1432        }
1433    }
1434
1435    pub(crate) fn html(content: String) -> Text {
1436        Text {
1437            content_type: MediaTypeBuf::new(names::TEXT, names::HTML),
1438            src: None,
1439            content: content.trim().to_string(),
1440        }
1441    }
1442
1443    pub fn sanitize(&mut self) {
1444        #[cfg(feature = "sanitize")]
1445        {
1446            if self.content_type.as_str() != "text/plain" {
1447                self.content = ammonia::clean(&self.content);
1448            }
1449        }
1450    }
1451}
1452
1453#[cfg(test)]
1454impl Text {
1455    pub fn content_type(mut self, content_type: &str) -> Self {
1456        self.content_type = content_type.parse::<MediaTypeBuf>().unwrap();
1457        self
1458    }
1459}
1460
1461/// Case-insensitive deserialisation for enum properties such as [Role].
1462pub(crate) fn podcast_enum<'de, T, D>(deserializer: D) -> Result<T, D::Error>
1463where
1464    T: DeserializeOwned,
1465    D: Deserializer<'de>,
1466{
1467    let value = String::deserialize(deserializer)?;
1468    let value = normalise_case(&value);
1469
1470    T::deserialize(Value::String(value)).map_err(serde::de::Error::custom)
1471}
1472
1473/// Convert "an example-string" to "An Example-String"
1474pub(crate) fn normalise_case(value: &str) -> String {
1475    let mut out = String::with_capacity(value.len());
1476    let value = value.to_lowercase();
1477
1478    let mut capitalise = true;
1479    for char in value.chars() {
1480        if capitalise {
1481            out.push(char.to_ascii_uppercase());
1482            capitalise = false;
1483        } else {
1484            out.push(char);
1485        }
1486
1487        if char == '-' || char == ' ' {
1488            capitalise = true;
1489        }
1490    }
1491
1492    out
1493}