feed_rs/
model.rs

1use std::time::Duration;
2
3use chrono::{DateTime, Utc};
4use mediatype::{names, MediaTypeBuf};
5use serde::Serialize;
6use url::Url;
7
8use crate::parser::util;
9#[cfg(test)]
10use crate::parser::util::parse_timestamp_lenient;
11
12/// Combined model for a syndication feed (i.e. RSS1, RSS 2, Atom, JSON Feed)
13///
14/// The model is based on the Atom standard as a start with RSS1+2 mapped on to it e.g.
15/// * Atom
16///     * Feed -> Feed
17///     * Entry -> Entry
18/// * RSS 1 + 2
19///     * Channel -> Feed
20///     * Item -> Entry
21///
22/// [Atom spec]: http://www.atomenabled.org/developers/syndication/
23/// [RSS 2 spec]: https://validator.w3.org/feed/docs/rss2.html
24/// [RSS 1 spec]: https://validator.w3.org/feed/docs/rss1.html
25/// [MediaRSS spec]: https://www.rssboard.org/media-rss
26/// [iTunes podcast spec]: https://help.apple.com/itc/podcasts_connect/#/itcb54353390
27/// [iTunes podcast guide]: https://www.feedforall.com/itune-tutorial-tags.htm
28///
29/// Certain elements are not mapped given their limited utility:
30///   * RSS 2:
31///     * channel - docs (pointer to the spec), cloud (for callbacks), textInput (text box e.g. for search)
32///     * item - comments (link to comments on the article), source (pointer to the channel, but our data model links items to a channel)
33///   * RSS 1:
34///     * channel - rdf:about attribute (pointer to feed), textinput (text box e.g. for search)
35#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
36pub struct Feed {
37    /// Type of this feed (e.g. RSS2, Atom etc)
38    pub feed_type: FeedType,
39    /// A unique identifier for this feed
40    /// * Atom (required): Identifies the feed using a universally unique and permanent URI.
41    /// * RSS doesn't require an ID so it is initialised to the hash of the first link or a UUID if not found
42    pub id: String,
43    /// The title of the feed
44    /// * 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.
45    /// * RSS 1 + 2 (required) "title": The name of the channel. It's how people refer to your service.
46    /// * JSON Feed: is the name of the feed
47    pub title: Option<Text>,
48    /// The time at which the feed was last modified. If not provided in the source, or invalid, it is `None`.
49    /// * Atom (required): Indicates the last time the feed was modified in a significant way.
50    /// * RSS 2 (optional) "lastBuildDate": The last time the content of the channel changed.
51    pub updated: Option<DateTime<Utc>>,
52
53    /// Atom (recommended): Collection of authors defined at the feed level.
54    /// JSON Feed: specifies the feed author.
55    pub authors: Vec<Person>,
56    /// Description of the feed
57    /// * Atom (optional): Contains a human-readable description or subtitle for the feed (from the subtitle element).
58    /// * RSS 1 + 2 (required): Phrase or sentence describing the channel.
59    /// * JSON Feed: description of the feed
60    pub description: Option<Text>,
61    /// Links to related pages
62    /// * Atom (recommended): Identifies a related Web page.
63    /// * RSS 1 + 2 (required): The URL to the HTML website corresponding to the channel.
64    /// * JSON Feed: the homepage and feed URLs
65    pub links: Vec<Link>,
66
67    /// Structured classification of the feed
68    /// * Atom (optional): Specifies a category that the feed belongs to. A feed may have multiple category elements.
69    /// * RSS 2 (optional) "category": Specify one or more categories that the channel belongs to.
70    pub categories: Vec<Category>,
71    /// People who have contributed to the feed
72    /// * Atom (optional): Names one contributor to the feed. A feed may have multiple contributor elements.
73    /// * RSS 2 (optional) "managingEditor": Email address for person responsible for editorial content.
74    /// * RSS 2 (optional) "webMaster": Email address for person responsible for technical issues relating to channel.
75    pub contributors: Vec<Person>,
76    /// Information on the software used to build the feed
77    /// * Atom (optional): Identifies the software used to generate the feed, for debugging and other purposes.
78    /// * RSS 2 (optional): A string indicating the program used to generate the channel.
79    pub generator: Option<Generator>,
80    /// A small icon
81    /// * Atom (optional): Identifies a small image which provides iconic visual identification for the feed.
82    /// * JSON Feed: is the URL of an image for the feed suitable to be used in a source list.
83    pub icon: Option<Image>,
84    /// RSS 2 (optional): The language the channel is written in.
85    pub language: Option<String>,
86    /// An image used to visually identify the feed
87    /// * Atom (optional): Identifies a larger image which provides visual identification for the feed.
88    /// * RSS 1 + 2 (optional) "image": Specifies a GIF, JPEG or PNG image that can be displayed with the channel.
89    /// * JSON Feed: is the URL of an image for the feed suitable to be used in a timeline
90    pub logo: Option<Image>,
91    /// RSS 2 (optional): The publication date for the content in the channel.
92    pub published: Option<DateTime<Utc>>,
93    /// Rating for the content
94    /// * Populated from the media or itunes namespaces
95    pub rating: Option<MediaRating>,
96    /// Rights restricting content within the feed
97    /// * Atom (optional): Conveys information about rights, e.g. copyrights, held in and over the feed.
98    /// * RSS 2 (optional) "copyright": Copyright notice for content in the channel.
99    pub rights: Option<Text>,
100    /// RSS 2 (optional): It's a number of minutes that indicates how long a channel can be cached before refreshing from the source.
101    pub ttl: Option<u32>,
102
103    /// The individual items within the feed
104    /// * Atom (optional): Individual entries within the feed (e.g. a blog post)
105    /// * RSS 1+2 (optional): Individual items within the channel.
106    pub entries: Vec<Entry>,
107}
108
109impl Feed {
110    pub(crate) fn new(feed_type: FeedType) -> Self {
111        Feed {
112            feed_type,
113            id: "".into(),
114            title: None,
115            updated: None,
116            authors: Vec::new(),
117            description: None,
118            links: Vec::new(),
119            categories: Vec::new(),
120            contributors: Vec::new(),
121            generator: None,
122            icon: None,
123            language: None,
124            logo: None,
125            published: None,
126            rating: None,
127            rights: None,
128            ttl: None,
129            entries: Vec::new(),
130        }
131    }
132}
133
134#[cfg(test)]
135impl Feed {
136    pub fn author(mut self, person: Person) -> Self {
137        self.authors.push(person);
138        self
139    }
140
141    pub fn category(mut self, category: Category) -> Self {
142        self.categories.push(category);
143        self
144    }
145
146    pub fn contributor(mut self, person: Person) -> Self {
147        self.contributors.push(person);
148        self
149    }
150
151    pub fn description(mut self, description: Text) -> Self {
152        self.description = Some(description);
153        self
154    }
155
156    pub fn entry(mut self, entry: Entry) -> Self {
157        self.entries.push(entry);
158        self
159    }
160
161    pub fn generator(mut self, generator: Generator) -> Self {
162        self.generator = Some(generator);
163        self
164    }
165
166    pub fn icon(mut self, image: Image) -> Self {
167        self.icon = Some(image);
168        self
169    }
170
171    pub fn id(mut self, id: &str) -> Self {
172        self.id = id.to_string();
173        self
174    }
175
176    pub fn language(mut self, language: &str) -> Self {
177        self.language = Some(language.to_owned());
178        self
179    }
180
181    pub fn link(mut self, link: Link) -> Self {
182        self.links.push(link);
183        self
184    }
185
186    pub fn logo(mut self, image: Image) -> Self {
187        self.logo = Some(image);
188        self
189    }
190
191    pub fn published(mut self, pub_date: &str) -> Self {
192        self.published = parse_timestamp_lenient(pub_date);
193        self
194    }
195
196    pub fn rights(mut self, rights: Text) -> Self {
197        self.rights = Some(rights);
198        self
199    }
200
201    pub fn title(mut self, title: Text) -> Self {
202        self.title = Some(title);
203        self
204    }
205
206    pub fn ttl(mut self, ttl: u32) -> Self {
207        self.ttl = Some(ttl);
208        self
209    }
210
211    pub fn updated(mut self, updated: Option<DateTime<Utc>>) -> Self {
212        self.updated = updated;
213        self
214    }
215
216    pub fn updated_parsed(mut self, updated: &str) -> Self {
217        self.updated = parse_timestamp_lenient(updated);
218        self
219    }
220}
221
222/// Type of a feed (RSS, Atom etc)
223#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
224pub enum FeedType {
225    Atom,
226    JSON,
227    RSS0,
228    RSS1,
229    RSS2,
230}
231
232/// An item within a feed
233#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
234pub struct Entry {
235    /// 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.
236    /// * Atom (required): Identifies the entry using a universally unique and permanent URI.
237    /// * RSS 2 (optional) "guid": A string that uniquely identifies the item.
238    /// * 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
239    /// * JSON Feed: is unique for that item for that feed over time.
240    pub id: String,
241    /// Title of this item within the feed
242    /// * Atom, RSS 1(required): Contains a human readable title for the entry.
243    /// * RSS 2 (optional): The title of the item.
244    /// * JSON Feed: The title of the item.
245    pub title: Option<Text>,
246    /// Time at which this item was last modified. If not provided in the source, or invalid, it is `None`.
247    /// * Atom (required): Indicates the last time the entry was modified in a significant way.
248    /// * RSS doesn't specify this field, so we copy it from the entry 'published' field for consistency.
249    /// * JSON Feed: the last modification date of this item
250    pub updated: Option<DateTime<Utc>>,
251
252    /// Authors of this item
253    /// * Atom (recommended): Collection of authors defined at the entry level.
254    /// * RSS 2 (optional): Email address of the author of the item.
255    /// * JSON Feed: the author of the item
256    pub authors: Vec<Person>,
257    /// The content of the item
258    /// * Atom (recommended): Contains or links to the complete content of the entry.
259    /// * RSS 2 (optional) "content:encoded": The HTML form of the content
260    /// * JSON Feed: the html content of the item, or the text content if no html is specified
261    pub content: Option<Content>,
262    /// Links associated with this item
263    /// * Atom (recommended): Identifies a related Web page.
264    /// * RSS 2 (optional): The URL of the item.
265    /// * RSS 1 (required): The item's URL.
266    /// * JSON Feed: the url and external URL for the item is the first items, then each subsequent attachment
267    pub links: Vec<Link>,
268    /// A short summary of the item
269    /// * Atom (recommended): Conveys a short summary, abstract, or excerpt of the entry.
270    /// * RSS 1 (optional): Populated from the RSS namespace 'description' field, or if not present, the Dublin Core namespace 'description' field.
271    /// * RSS 2 (optional): Populated from the RSS namespace 'description' field.
272    /// * JSON Feed: the summary for the item, or the text content if no summary is provided and both text and html content are specified
273    ///
274    /// 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.
275    pub summary: Option<Text>,
276
277    /// Structured classification of the item
278    /// * Atom (optional): Specifies a category that the entry belongs to. A feed may have multiple category elements.
279    /// * RSS 2 (optional): Includes the item in one or more categories.
280    /// * JSON Feed: the supplied item tags
281    pub categories: Vec<Category>,
282    /// Atom (optional): Names one contributor to the entry. A feed may have multiple contributor elements.
283    pub contributors: Vec<Person>,
284    /// Time at which this item was first published
285    /// * Atom (optional): Contains the time of the initial creation or first availability of the entry.
286    /// * RSS 2 (optional) "pubDate": Indicates when the item was published.
287    /// * JSON Feed: the date at which the item was published
288    pub published: Option<DateTime<Utc>>,
289    /// Atom (optional): If an entry is copied from one feed into another feed, then this contains the source feed metadata.
290    pub source: Option<String>,
291    /// Atom (optional): Conveys information about rights, e.g. copyrights, held in and over the feed.
292    pub rights: Option<Text>,
293
294    /// Extension for MediaRSS - <https://www.rssboard.org/media-rss>
295    /// A MediaObject will be created in two cases:
296    /// 1) each "media:group" element encountered in the feed
297    /// 2) a default for any other "media:*" elements found at the item level
298    ///
299    /// See the Atom tests for youtube and newscred for examples
300    pub media: Vec<MediaObject>,
301
302    /// Atom (optional): The language specified on the item
303    pub language: Option<String>,
304    /// Atom (optional): The base url specified on the item to resolve any relative
305    /// references found within the scope on the item
306    pub base: Option<String>,
307}
308
309impl Default for Entry {
310    fn default() -> Self {
311        Entry {
312            id: "".into(),
313            title: None,
314            updated: None,
315            authors: Vec::new(),
316            content: None,
317            links: Vec::new(),
318            summary: None,
319            categories: Vec::new(),
320            contributors: Vec::new(),
321            published: None,
322            source: None,
323            rights: None,
324            media: Vec::new(),
325            language: None,
326            base: None,
327        }
328    }
329}
330
331#[cfg(test)]
332impl Entry {
333    pub fn author(mut self, person: Person) -> Self {
334        self.authors.push(person);
335        self
336    }
337
338    pub fn category(mut self, category: Category) -> Self {
339        self.categories.push(category);
340        self
341    }
342
343    pub fn content(mut self, content: Content) -> Self {
344        self.content = Some(content);
345        self
346    }
347
348    pub fn contributor(mut self, person: Person) -> Self {
349        self.contributors.push(person);
350        self
351    }
352
353    pub fn id(mut self, id: &str) -> Self {
354        self.id = id.trim().to_string();
355        self
356    }
357
358    pub fn link(mut self, link: Link) -> Self {
359        self.links.push(link);
360        self
361    }
362
363    pub fn published(mut self, published: &str) -> Self {
364        self.published = parse_timestamp_lenient(published);
365        self
366    }
367
368    pub fn rights(mut self, rights: Text) -> Self {
369        self.rights = Some(rights);
370        self
371    }
372
373    pub fn summary(mut self, summary: Text) -> Self {
374        self.summary = Some(summary);
375        self
376    }
377
378    pub fn title(mut self, title: Text) -> Self {
379        self.title = Some(title);
380        self
381    }
382
383    pub fn updated(mut self, updated: Option<DateTime<Utc>>) -> Self {
384        self.updated = updated;
385        self
386    }
387
388    pub fn updated_parsed(mut self, updated: &str) -> Self {
389        self.updated = parse_timestamp_lenient(updated);
390        self
391    }
392
393    pub fn media(mut self, media: MediaObject) -> Self {
394        self.media.push(media);
395        self
396    }
397
398    pub fn language(mut self, language: &str) -> Self {
399        self.language = Some(language.to_owned());
400        self
401    }
402
403    pub fn base(mut self, url: &str) -> Self {
404        self.base = Some(url.to_owned());
405        self
406    }
407}
408
409/// Represents the category of a feed or entry
410///
411/// [Atom spec]: http://www.atomenabled.org/developers/syndication/#category
412/// [RSS 2 spec]: https://validator.w3.org/feed/docs/rss2.html#ltcategorygtSubelementOfLtitemgt
413#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
414pub struct Category {
415    /// The category as a human readable string
416    /// * Atom (required): Identifies the category.
417    /// * 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.
418    /// * JSON Feed: the value of the tag
419    pub term: String,
420    /// Atom (optional): Identifies the categorization scheme via a URI.
421    pub scheme: Option<String>,
422    /// Atom (optional): Provides a human-readable label for display.
423    pub label: Option<String>,
424    /// Sub-categories (typically from the iTunes namespace i.e. <https://help.apple.com/itc/podcasts_connect/#/itcb54353390>)
425    pub subcategories: Vec<Category>,
426}
427
428impl Category {
429    pub fn new(term: &str) -> Category {
430        Category {
431            term: term.trim().into(),
432            scheme: None,
433            label: None,
434            subcategories: Vec::new(),
435        }
436    }
437}
438
439#[cfg(test)]
440impl Category {
441    pub fn label(mut self, label: &str) -> Self {
442        self.label = Some(label.to_owned());
443        self
444    }
445
446    pub fn scheme(mut self, scheme: &str) -> Self {
447        self.scheme = Some(scheme.to_owned());
448        self
449    }
450}
451
452/// Content, or link to the content, for a given entry.
453///
454/// [Atom spec]: http://www.atomenabled.org/developers/syndication/#contentElement
455/// [RSS 2.0]: https://validator.w3.org/feed/docs/rss2.html#ltenclosuregtSubelementOfLtitemgt
456#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
457pub struct Content {
458    /// Atom
459    /// * If the type attribute ends in +xml or /xml, then an xml document of this type is contained inline.
460    /// * If the type attribute starts with text, then an escaped document of this type is contained inline.
461    /// * Otherwise a base64 encoded document of the indicated media type is contained inline.
462    pub body: Option<String>,
463    /// Type of content
464    /// * Atom: The type attribute is either text, html, xhtml, in which case the content element is defined identically to other text constructs.
465    /// * RSS 2: Type says what its type is, a standard MIME type
466    pub content_type: MediaTypeBuf,
467    /// RSS 2.0: Length of the content in bytes
468    pub length: Option<u64>,
469    /// Source of the content
470    /// * 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.
471    /// * RSS 2.0: where the enclosure is located
472    pub src: Option<Link>,
473}
474
475impl Default for Content {
476    fn default() -> Content {
477        Content {
478            body: None,
479            content_type: MediaTypeBuf::new(names::TEXT, names::PLAIN),
480            length: None,
481            src: None,
482        }
483    }
484}
485
486impl Content {
487    pub fn sanitize(&mut self) {
488        // We're dealing with a broader variety of possible content types than
489        // in Text, since the possibility exists that we'll be dealing with a base64-encode
490        // image or the like, so we'll target a correspondingly tighter set: text/html
491        // and application/xhtml+xml.
492        #[cfg(feature = "sanitize")]
493        {
494            let content_type = self.content_type.as_str();
495            if content_type == "text/html" || content_type == "application/xhtml+xml" {
496                if let Some(body) = &self.body {
497                    self.body = Some(ammonia::clean(body.as_str()));
498                }
499            }
500        }
501    }
502}
503
504#[cfg(test)]
505impl Content {
506    pub fn body(mut self, body: &str) -> Self {
507        self.body = Some(body.to_owned());
508        self
509    }
510
511    pub fn content_type(mut self, content_type: &str) -> Self {
512        self.content_type = content_type.parse::<MediaTypeBuf>().unwrap();
513        self
514    }
515
516    pub fn length(mut self, length: u64) -> Self {
517        self.length = Some(length);
518        self
519    }
520
521    pub fn src(mut self, url: &str) -> Self {
522        self.src = Some(Link::new(url, None));
523        self
524    }
525}
526
527/// Information on the tools used to generate the feed
528///
529/// Atom: Identifies the software used to generate the feed, for debugging and other purposes.
530#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
531pub struct Generator {
532    /// Atom: Additional data
533    /// RSS 2: A string indicating the program used to generate the channel.
534    pub content: String,
535    /// Atom: Link to the tool
536    pub uri: Option<String>,
537    /// Atom: Tool version
538    pub version: Option<String>,
539}
540
541impl Generator {
542    pub(crate) fn new(content: &str) -> Generator {
543        Generator {
544            uri: None,
545            version: None,
546            content: content.trim().into(),
547        }
548    }
549}
550
551#[cfg(test)]
552impl Generator {
553    pub fn uri(mut self, uri: &str) -> Self {
554        self.uri = Some(uri.to_owned());
555        self
556    }
557
558    pub fn version(mut self, version: &str) -> Self {
559        self.version = Some(version.to_owned());
560        self
561    }
562}
563
564/// Represents a a link to an image.
565///
566/// [Atom spec]:  http://www.atomenabled.org/developers/syndication/#optionalFeedElements
567/// [RSS 2 spec]: https://validator.w3.org/feed/docs/rss2.html#ltimagegtSubelementOfLtchannelgt
568/// [RSS 1 spec]: https://validator.w3.org/feed/docs/rss1.html#s5.4
569#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
570pub struct Image {
571    /// Link to the image
572    /// * Atom: The URL to an image or logo
573    /// * RSS 1 + 2: the URL of a GIF, JPEG or PNG image that represents the channel.
574    pub uri: String,
575    /// 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.
576    pub title: Option<String>,
577    /// RSS 1 + 2: the URL of the site, when the channel is rendered, the image is a link to the site.
578    pub link: Option<Link>,
579
580    /// RSS 2 (optional): width of the image
581    pub width: Option<u32>,
582    /// RSS 2 (optional): height of the image
583    pub height: Option<u32>,
584    /// RSS 2 (optional): contains text that is included in the TITLE attribute of the link formed around the image in the HTML rendering.
585    pub description: Option<String>,
586}
587
588impl Image {
589    pub(crate) fn new(uri: String) -> Image {
590        Image {
591            uri,
592            title: None,
593            link: None,
594            width: None,
595            height: None,
596            description: None,
597        }
598    }
599}
600
601#[cfg(test)]
602impl Image {
603    pub fn description(mut self, description: &str) -> Self {
604        self.description = Some(description.to_owned());
605        self
606    }
607
608    pub fn height(mut self, height: u32) -> Self {
609        self.height = Some(height);
610        self
611    }
612
613    pub fn link(mut self, link: &str) -> Self {
614        self.link = Some(Link::new(link, None));
615        self
616    }
617
618    pub fn title(mut self, title: &str) -> Self {
619        self.title = Some(title.to_owned());
620        self
621    }
622
623    pub fn width(mut self, width: u32) -> Self {
624        self.width = Some(width);
625        self
626    }
627}
628
629/// Represents a link to an associated resource for the feed or entry.
630///
631/// [Atom spec]: http://www.atomenabled.org/developers/syndication/#link
632#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
633pub struct Link {
634    /// Link to additional content
635    /// * Atom: The URI of the referenced resource (typically a Web page).
636    /// * RSS 2: The URL to the HTML website corresponding to the channel or item.
637    /// * JSON Feed: the URI to the attachment, feed etc
638    pub href: String,
639    /// A single link relationship type.
640    pub rel: Option<String>,
641    /// Indicates the media type of the resource.
642    pub media_type: Option<String>,
643    /// Indicates the language of the referenced resource.
644    pub href_lang: Option<String>,
645    /// Human readable information about the link, typically for display purposes.
646    pub title: Option<String>,
647    /// The length of the resource, in bytes.
648    pub length: Option<u64>,
649}
650
651impl Link {
652    pub(crate) fn new<S: AsRef<str>>(href: S, base: Option<&Url>) -> Link {
653        let href = match util::parse_uri(href.as_ref(), base) {
654            Some(uri) => uri.to_string(),
655            None => href.as_ref().to_string(),
656        }
657        .trim()
658        .to_string();
659
660        Link {
661            href,
662            rel: None,
663            media_type: None,
664            href_lang: None,
665            title: None,
666            length: None,
667        }
668    }
669}
670
671#[cfg(test)]
672impl Link {
673    pub fn href_lang(mut self, lang: &str) -> Self {
674        self.href_lang = Some(lang.to_owned());
675        self
676    }
677
678    pub fn length(mut self, length: u64) -> Self {
679        self.length = Some(length);
680        self
681    }
682
683    pub fn media_type(mut self, media_type: &str) -> Self {
684        self.media_type = Some(media_type.to_owned());
685        self
686    }
687
688    pub fn rel(mut self, rel: &str) -> Self {
689        self.rel = Some(rel.to_owned());
690        self
691    }
692
693    pub fn title(mut self, title: &str) -> Self {
694        self.title = Some(title.to_owned());
695        self
696    }
697}
698
699/// The top-level representation of a media object
700/// i.e. combines "media:*" elements from the RSS Media spec such as those under a media:group
701#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
702pub struct MediaObject {
703    /// Title of the object (from the media:title element)
704    pub title: Option<Text>,
705    /// Collection of the media content elements
706    pub content: Vec<MediaContent>,
707    /// Duration of the object
708    pub duration: Option<Duration>,
709    /// Representative images for the object (from media:thumbnail elements)
710    pub thumbnails: Vec<MediaThumbnail>,
711    /// A text transcript, closed captioning or lyrics of the media content.
712    pub texts: Vec<MediaText>,
713    /// Short description of the media object (from the media:description element)
714    pub description: Option<Text>,
715    /// Community info (from the media:community element)
716    pub community: Option<MediaCommunity>,
717    /// Credits
718    pub credits: Vec<MediaCredit>,
719}
720
721impl MediaObject {
722    // Checks if this object has been populated with content
723    pub(crate) fn has_content(&self) -> bool {
724        self.title.is_some() || self.description.is_some() || !self.content.is_empty() || !self.thumbnails.is_empty() || !self.texts.is_empty()
725    }
726}
727
728#[cfg(test)]
729impl MediaObject {
730    pub fn community(mut self, community: MediaCommunity) -> Self {
731        self.community = Some(community);
732        self
733    }
734
735    pub fn content(mut self, content: MediaContent) -> Self {
736        self.content.push(content);
737        self
738    }
739
740    pub fn credit(mut self, entity: &str) -> Self {
741        self.credits.push(MediaCredit::new(entity.to_string()));
742        self
743    }
744
745    pub fn description(mut self, description: &str) -> Self {
746        self.description = Some(Text::new(description.to_string()));
747        self
748    }
749
750    pub fn duration(mut self, duration: Duration) -> Self {
751        self.duration = Some(duration);
752        self
753    }
754
755    pub fn text(mut self, text: MediaText) -> Self {
756        self.texts.push(text);
757        self
758    }
759
760    pub fn thumbnail(mut self, thumbnail: MediaThumbnail) -> Self {
761        self.thumbnails.push(thumbnail);
762        self
763    }
764
765    pub fn title(mut self, title: &str) -> Self {
766        self.title = Some(Text::new(title.to_string()));
767        self
768    }
769}
770
771/// Represents a "media:community" item from the RSS Media spec
772#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
773pub struct MediaCommunity {
774    /// Star rating
775    pub stars_avg: Option<f64>,
776    pub stars_count: Option<u64>,
777    pub stars_min: Option<u64>,
778    pub stars_max: Option<u64>,
779
780    /// Statistics on engagement
781    pub stats_views: Option<u64>,
782    pub stats_favorites: Option<u64>,
783}
784
785impl MediaCommunity {
786    pub(crate) fn new() -> MediaCommunity {
787        MediaCommunity {
788            stars_avg: None,
789            stars_count: None,
790            stars_min: None,
791            stars_max: None,
792            stats_views: None,
793            stats_favorites: None,
794        }
795    }
796}
797
798#[cfg(test)]
799impl MediaCommunity {
800    pub fn star_rating(mut self, count: u64, average: f64, min: u64, max: u64) -> Self {
801        self.stars_count = Some(count);
802        self.stars_avg = Some(average);
803        self.stars_min = Some(min);
804        self.stars_max = Some(max);
805        self
806    }
807
808    pub fn statistics(mut self, views: u64, favorites: u64) -> Self {
809        self.stats_views = Some(views);
810        self.stats_favorites = Some(favorites);
811        self
812    }
813}
814
815/// Represents a "media:content" item from the RSS Media spec
816#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
817pub struct MediaContent {
818    /// The direct URL
819    pub url: Option<Url>,
820    /// Standard MIME type
821    pub content_type: Option<MediaTypeBuf>,
822    /// Height and width
823    pub height: Option<u32>,
824    pub width: Option<u32>,
825    /// Duration the media plays
826    pub duration: Option<Duration>,
827    /// Size of media in bytes
828    pub size: Option<u64>,
829    /// Rating
830    pub rating: Option<MediaRating>,
831}
832
833#[cfg(test)]
834impl MediaContent {
835    pub fn content_type(mut self, content_type: &str) -> Self {
836        self.content_type = Some(content_type.parse::<MediaTypeBuf>().unwrap());
837        self
838    }
839
840    pub fn height(mut self, height: u32) -> Self {
841        self.height = Some(height);
842        self
843    }
844
845    pub fn url(mut self, url: &str) -> Self {
846        self.url = Some(Url::parse(url).unwrap());
847        self
848    }
849
850    pub fn width(mut self, width: u32) -> Self {
851        self.width = Some(width);
852        self
853    }
854
855    pub fn duration(mut self, duration: Duration) -> Self {
856        self.duration = Some(duration);
857        self
858    }
859
860    pub fn size(mut self, size: u64) -> Self {
861        self.size = Some(size);
862        self
863    }
864}
865
866impl MediaContent {
867    pub(crate) fn new() -> MediaContent {
868        MediaContent {
869            url: None,
870            content_type: None,
871            height: None,
872            width: None,
873            duration: None,
874            size: None,
875            rating: None,
876        }
877    }
878}
879
880/// Represents a "media:credit" item from the RSS Media spec
881#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
882pub struct MediaCredit {
883    /// The entity being credited
884    pub entity: String,
885}
886
887impl MediaCredit {
888    pub(crate) fn new(entity: String) -> MediaCredit {
889        MediaCredit { entity }
890    }
891}
892
893/// Rating of the feed, item or media within the content
894#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
895pub struct MediaRating {
896    // The scheme (defaults to "simple" per the spec)
897    pub urn: String,
898    // The rating text
899    pub value: String,
900}
901
902impl MediaRating {
903    pub(crate) fn new(value: String) -> MediaRating {
904        MediaRating { urn: "simple".into(), value }
905    }
906
907    pub fn urn(mut self, urn: &str) -> Self {
908        self.urn = urn.to_string();
909        self
910    }
911}
912
913/// Represents a "media:text" item from the RSS Media spec
914#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
915pub struct MediaText {
916    /// The text
917    pub text: Text,
918    /// The start time offset that the text starts being relevant to the media object.
919    pub start_time: Option<Duration>,
920    /// 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.
921    pub end_time: Option<Duration>,
922}
923
924impl MediaText {
925    pub(crate) fn new(text: Text) -> MediaText {
926        MediaText {
927            text,
928            start_time: None,
929            end_time: None,
930        }
931    }
932}
933
934/// Represents a "media:thumbnail" item from the RSS Media spec
935#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
936pub struct MediaThumbnail {
937    /// The thumbnail image
938    pub image: Image,
939    /// The time this thumbnail represents
940    pub time: Option<Duration>,
941}
942
943impl MediaThumbnail {
944    pub(crate) fn new(image: Image) -> MediaThumbnail {
945        MediaThumbnail { image, time: None }
946    }
947}
948
949/// Represents an author, contributor etc.
950///
951/// [Atom spec]: http://www.atomenabled.org/developers/syndication/#person
952#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
953pub struct Person {
954    /// Atom: human-readable name for the person.
955    /// JSON Feed: human-readable name for the person.
956    pub name: String,
957    /// Atom: home page for the person.
958    /// JSON Feed: link to media (Twitter etc) for the person
959    pub uri: Option<String>,
960    /// Atom: An email address for the person.
961    pub email: Option<String>,
962}
963
964impl Person {
965    pub(crate) fn new(name: &str) -> Person {
966        Person {
967            name: name.trim().into(),
968            uri: None,
969            email: None,
970        }
971    }
972
973    pub fn email(mut self, email: &str) -> Self {
974        self.email = Some(email.to_owned());
975        self
976    }
977}
978
979#[cfg(test)]
980impl Person {
981    pub fn uri(mut self, uri: &str) -> Self {
982        self.uri = Some(uri.to_owned());
983        self
984    }
985}
986
987/// Textual content, or link to the content, for a given entry.
988#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
989pub struct Text {
990    pub content_type: MediaTypeBuf,
991    pub src: Option<String>,
992    pub content: String,
993}
994
995impl Text {
996    pub(crate) fn new(content: String) -> Text {
997        Text {
998            content_type: MediaTypeBuf::new(names::TEXT, names::PLAIN),
999            src: None,
1000            content: content.trim().to_string(),
1001        }
1002    }
1003
1004    pub(crate) fn html(content: String) -> Text {
1005        Text {
1006            content_type: MediaTypeBuf::new(names::TEXT, names::HTML),
1007            src: None,
1008            content: content.trim().to_string(),
1009        }
1010    }
1011
1012    pub fn sanitize(&mut self) {
1013        #[cfg(feature = "sanitize")]
1014        {
1015            if self.content_type.as_str() != "text/plain" {
1016                self.content = ammonia::clean(&self.content);
1017            }
1018        }
1019    }
1020}
1021
1022#[cfg(test)]
1023impl Text {
1024    pub fn content_type(mut self, content_type: &str) -> Self {
1025        self.content_type = content_type.parse::<MediaTypeBuf>().unwrap();
1026        self
1027    }
1028}