news_flash/models/
thumbnail.rs1use 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}