Skip to main content

news_flash/models/
thumbnail.rs

1use crate::models::{ArticleID, Url};
2use crate::schema::thumbnails;
3use chrono::{DateTime, Utc};
4use error::ThumbnailError;
5use image::codecs::png::PngEncoder;
6use image::{GenericImageView, ImageEncoder, ImageReader, imageops};
7use reqwest::{Client, header};
8use std::io::Cursor;
9
10const THUMB_DEST_HEIGHT: u32 = 128;
11
12#[derive(Identifiable, Queryable, Clone, Debug, Insertable, Eq)]
13#[diesel(primary_key(article_id))]
14#[diesel(table_name = thumbnails)]
15#[diesel(check_for_backend(SQLite))]
16pub struct Thumbnail {
17    pub article_id: ArticleID,
18    #[diesel(column_name = "date")]
19    pub last_try: DateTime<Utc>,
20    pub format: Option<String>,
21    pub etag: Option<String>,
22    pub source_url: Option<Url>,
23    pub data: Option<Vec<u8>>,
24    pub width: Option<i32>,
25    pub height: Option<i32>,
26}
27
28impl PartialEq for Thumbnail {
29    fn eq(&self, other: &Thumbnail) -> bool {
30        self.article_id == other.article_id
31    }
32}
33
34impl Thumbnail {
35    pub fn empty(article_id: &ArticleID) -> Self {
36        Thumbnail {
37            article_id: article_id.clone(),
38            last_try: Utc::now(),
39            format: None,
40            etag: None,
41            source_url: None,
42            data: None,
43            width: None,
44            height: None,
45        }
46    }
47
48    pub async fn from_url(url: &str, article_id: &ArticleID, client: &Client) -> Result<Self, ThumbnailError> {
49        let head_res = client.head(url).send().await?;
50        if let Some(Ok(content_type)) = head_res.headers().get(header::CONTENT_TYPE).map(|hval| hval.to_str()) {
51            if !content_type.starts_with("image") {
52                tracing::debug!(%url, mime = content_type, "Thumnail URL doesn't point to an image");
53                return Err(ThumbnailError::NotAnImage);
54            }
55        } else {
56            tracing::debug!(%url, "No content type header value set for image URL");
57            return Err(ThumbnailError::NotAnImage);
58        }
59
60        let res = client.get(url).send().await?;
61
62        let etag = res
63            .headers()
64            .get(reqwest::header::ETAG)
65            .and_then(|hval| hval.to_str().ok())
66            .map(|etag| etag.into());
67
68        let image_data = res.bytes().await?;
69
70        let image = ImageReader::new(Cursor::new(image_data))
71            .with_guessed_format()
72            .map_err(|_| ThumbnailError::GuessFormat)?
73            .decode()
74            .map_err(|_| ThumbnailError::Decode)?;
75
76        let (original_width, original_height) = image.dimensions();
77        let (thumb_width, thumb_height) = Self::calc_thumb_dimensions(original_width, original_height);
78
79        let thumbnail = imageops::thumbnail(&image, thumb_width, thumb_height);
80        let (width, height) = thumbnail.dimensions();
81
82        let thumbnail_data = thumbnail.into_vec();
83
84        let mut dest = Cursor::new(Vec::new());
85        let encoder = PngEncoder::new(&mut dest);
86        encoder
87            .write_image(&thumbnail_data, width, height, image::ExtendedColorType::Rgba8)
88            .map_err(|_| ThumbnailError::Encode)?;
89
90        Ok(Thumbnail {
91            article_id: article_id.clone(),
92            last_try: Utc::now(),
93            format: Some("image/png".into()),
94            etag,
95            source_url: Some(Url::parse(url).unwrap()),
96            data: Some(dest.into_inner()),
97            width: Some(width as i32),
98            height: Some(height as i32),
99        })
100    }
101
102    fn calc_thumb_dimensions(original_width: u32, original_height: u32) -> (u32, u32) {
103        if original_height <= THUMB_DEST_HEIGHT {
104            return (original_width, original_height);
105        }
106
107        let ratio = (original_width as f64) / (original_height as f64);
108        ((THUMB_DEST_HEIGHT as f64 * ratio) as u32, THUMB_DEST_HEIGHT)
109    }
110}
111
112mod error {
113    use thiserror::Error;
114
115    #[derive(Error, Debug)]
116    pub enum ThumbnailError {
117        #[error("Failed to decode image")]
118        Decode,
119        #[error("Failed to guess image format")]
120        GuessFormat,
121        #[error("Failed to encode image")]
122        Encode,
123        #[error("Http request failed")]
124        Http(#[from] reqwest::Error),
125        #[error("Url doesn't point to an image")]
126        NotAnImage,
127        #[error("Unknown Error")]
128        Unknown,
129    }
130}
131
132#[cfg(test)]
133mod test {
134    use super::Thumbnail;
135    use crate::models::ArticleID;
136    use once_cell::sync::Lazy;
137    use reqwest::Client;
138    use test_log::test;
139
140    #[test(tokio::test)]
141    async fn golem_bitcoin() {
142        let url = "https://www.golem.de/2102/154364-260020-260017_rc.jpg";
143        let client: Lazy<Client> = Lazy::new(Client::new);
144        let _thumb = Thumbnail::from_url(url, &ArticleID::new("asf"), &client).await.unwrap();
145    }
146
147    #[test(tokio::test)]
148    async fn feaneron() {
149        let url = "https://feaneron.files.wordpress.com/2019/05/captura-de-tela-de-2019-05-31-17-48-43.png?w=1200";
150        let client: Lazy<Client> = Lazy::new(Client::new);
151        let _thumb = Thumbnail::from_url(url, &ArticleID::new("asf"), &client).await.unwrap();
152    }
153}