news-flash 3.0.1

Base library for a modern feed reader
Documentation
use crate::models::{ArticleID, Url};
use crate::schema::thumbnails;
use chrono::{DateTime, Utc};
use error::ThumbnailError;
use image::codecs::png::PngEncoder;
use image::{GenericImageView, ImageEncoder, ImageReader, imageops};
use reqwest::{Client, header};
use std::io::Cursor;

const THUMB_DEST_HEIGHT: u32 = 128;

#[derive(Identifiable, Queryable, Clone, Debug, Insertable, Eq)]
#[diesel(primary_key(article_id))]
#[diesel(table_name = thumbnails)]
#[diesel(check_for_backend(SQLite))]
pub struct Thumbnail {
    pub article_id: ArticleID,
    #[diesel(column_name = "date")]
    pub last_try: DateTime<Utc>,
    pub format: Option<String>,
    pub etag: Option<String>,
    pub source_url: Option<Url>,
    pub data: Option<Vec<u8>>,
    pub width: Option<i32>,
    pub height: Option<i32>,
}

impl PartialEq for Thumbnail {
    fn eq(&self, other: &Thumbnail) -> bool {
        self.article_id == other.article_id
    }
}

impl Thumbnail {
    pub fn empty(article_id: &ArticleID) -> Self {
        Thumbnail {
            article_id: article_id.clone(),
            last_try: Utc::now(),
            format: None,
            etag: None,
            source_url: None,
            data: None,
            width: None,
            height: None,
        }
    }

    pub async fn from_url(url: &str, article_id: &ArticleID, client: &Client) -> Result<Self, ThumbnailError> {
        let head_res = client.head(url).send().await?;
        if let Some(Ok(content_type)) = head_res.headers().get(header::CONTENT_TYPE).map(|hval| hval.to_str()) {
            if !content_type.starts_with("image") {
                tracing::debug!(%url, mime = content_type, "Thumnail URL doesn't point to an image");
                return Err(ThumbnailError::NotAnImage);
            }
        } else {
            tracing::debug!(%url, "No content type header value set for image URL");
            return Err(ThumbnailError::NotAnImage);
        }

        let res = client.get(url).send().await?;

        let etag = res
            .headers()
            .get(reqwest::header::ETAG)
            .and_then(|hval| hval.to_str().ok())
            .map(|etag| etag.into());

        let image_data = res.bytes().await?;

        let image = ImageReader::new(Cursor::new(image_data))
            .with_guessed_format()
            .map_err(|_| ThumbnailError::GuessFormat)?
            .decode()
            .map_err(|_| ThumbnailError::Decode)?;

        let (original_width, original_height) = image.dimensions();
        let (thumb_width, thumb_height) = Self::calc_thumb_dimensions(original_width, original_height);

        let thumbnail = imageops::thumbnail(&image, thumb_width, thumb_height);
        let (width, height) = thumbnail.dimensions();

        let thumbnail_data = thumbnail.into_vec();

        let mut dest = Cursor::new(Vec::new());
        let encoder = PngEncoder::new(&mut dest);
        encoder
            .write_image(&thumbnail_data, width, height, image::ExtendedColorType::Rgba8)
            .map_err(|_| ThumbnailError::Encode)?;

        Ok(Thumbnail {
            article_id: article_id.clone(),
            last_try: Utc::now(),
            format: Some("image/png".into()),
            etag,
            source_url: Some(Url::parse(url).unwrap()),
            data: Some(dest.into_inner()),
            width: Some(width as i32),
            height: Some(height as i32),
        })
    }

    fn calc_thumb_dimensions(original_width: u32, original_height: u32) -> (u32, u32) {
        if original_height <= THUMB_DEST_HEIGHT {
            return (original_width, original_height);
        }

        let ratio = (original_width as f64) / (original_height as f64);
        ((THUMB_DEST_HEIGHT as f64 * ratio) as u32, THUMB_DEST_HEIGHT)
    }
}

mod error {
    use thiserror::Error;

    #[derive(Error, Debug)]
    pub enum ThumbnailError {
        #[error("Failed to decode image")]
        Decode,
        #[error("Failed to guess image format")]
        GuessFormat,
        #[error("Failed to encode image")]
        Encode,
        #[error("Http request failed")]
        Http(#[from] reqwest::Error),
        #[error("Url doesn't point to an image")]
        NotAnImage,
        #[error("Unknown Error")]
        Unknown,
    }
}

#[cfg(test)]
mod test {
    use super::Thumbnail;
    use crate::models::ArticleID;
    use once_cell::sync::Lazy;
    use reqwest::Client;
    use test_log::test;

    #[test(tokio::test)]
    async fn golem_bitcoin() {
        let url = "https://www.golem.de/2102/154364-260020-260017_rc.jpg";
        let client: Lazy<Client> = Lazy::new(Client::new);
        let _thumb = Thumbnail::from_url(url, &ArticleID::new("asf"), &client).await.unwrap();
    }

    #[test(tokio::test)]
    async fn feaneron() {
        let url = "https://feaneron.files.wordpress.com/2019/05/captura-de-tela-de-2019-05-31-17-48-43.png?w=1200";
        let client: Lazy<Client> = Lazy::new(Client::new);
        let _thumb = Thumbnail::from_url(url, &ArticleID::new("asf"), &client).await.unwrap();
    }
}