feat: improve fetching images, show actual source from safebooru and add loading spinner

This commit is contained in:
dusk 2024-11-02 03:34:38 +03:00
parent 3e7076c748
commit 2bb23ea92a
Signed by: dusk
SSH Key Fingerprint: SHA256:Abmvag+juovVufZTxyWY8KcVgrznxvBjQpJesv071Aw
5 changed files with 154 additions and 45 deletions

29
Cargo.lock generated
View File

@ -228,9 +228,9 @@ dependencies = [
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.30" version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink", "futures-sink",
@ -238,9 +238,9 @@ dependencies = [
[[package]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.30" version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]] [[package]]
name = "futures-executor" name = "futures-executor"
@ -255,15 +255,15 @@ dependencies = [
[[package]] [[package]]
name = "futures-io" name = "futures-io"
version = "0.3.30" version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]] [[package]]
name = "futures-macro" name = "futures-macro"
version = "0.3.30" version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -283,21 +283,21 @@ dependencies = [
[[package]] [[package]]
name = "futures-sink" name = "futures-sink"
version = "0.3.30" version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
[[package]] [[package]]
name = "futures-task" name = "futures-task"
version = "0.3.30" version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
[[package]] [[package]]
name = "futures-util" name = "futures-util"
version = "0.3.30" version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [ dependencies = [
"futures-channel", "futures-channel",
"futures-core", "futures-core",
@ -615,6 +615,7 @@ dependencies = [
"fastrand", "fastrand",
"form_urlencoded", "form_urlencoded",
"futures-retry", "futures-retry",
"futures-util",
"http", "http",
"maud", "maud",
"reqwest", "reqwest",

View File

@ -15,3 +15,4 @@ signal-hook = "0.3"
serde_json = "1" serde_json = "1"
form_urlencoded = "1" form_urlencoded = "1"
futures-retry = "0.6" futures-retry = "0.6"
futures-util = "0.3.31"

View File

@ -77,3 +77,9 @@ impl Data {
Ok(()) Ok(())
} }
} }
#[derive(Clone)]
pub(crate) struct FetchedLink {
pub(crate) image_url: String,
pub(crate) new_source: Option<Uri>,
}

View File

@ -40,7 +40,7 @@ impl IntoResponse for AppError {
(crate::get_page_head_common()) (crate::get_page_head_common())
} }
body style=(crate::BODY_STYLE) { body style=(crate::BODY_STYLE) {
p style=(format!("{} font-size: 1.3em;", crate::IMG_STYLE)) { p style=("display: block; margin: auto; font-size: 1.3em;") {
"Something went wrong: " "Something went wrong: "
br; br;
(self.internal.to_string()); (self.internal.to_string());

View File

@ -5,12 +5,14 @@ use axum::{
Router, Router,
}; };
use dashmap::DashMap; use dashmap::DashMap;
use data::{Art, ArtKind, Data}; use data::{Art, ArtKind, Data, FetchedLink};
use error::AppResult; use error::{AppError, AppResult};
use futures_util::{future::BoxFuture, FutureExt};
use http::Uri; use http::Uri;
use maud::PreEscaped; use maud::PreEscaped;
use std::{ use std::{
ops::Deref, ops::Deref,
str::FromStr,
sync::{Arc, Mutex}, sync::{Arc, Mutex},
}; };
@ -19,10 +21,11 @@ mod error;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let arts_file_path = get_conf("ARTS_PATH"); let arts_file_path = get_conf("ARTS_PATH").unwrap_or_else(|| "./utils/arts.txt".to_string());
let arts = std::fs::read_to_string(&arts_file_path).unwrap(); let arts = std::fs::read_to_string(&arts_file_path).unwrap();
let state = AppState::new(Data::parse(&arts).unwrap()); let state = AppState::new(Data::parse(&arts).unwrap());
#[cfg(not(windows))]
std::thread::spawn({ std::thread::spawn({
use signal_hook::{consts::SIGUSR2, iterator::Signals}; use signal_hook::{consts::SIGUSR2, iterator::Signals};
@ -62,12 +65,13 @@ async fn show_art(
let art = state.data.lock().unwrap().pick_random_art().clone(); let art = state.data.lock().unwrap().pick_random_art().clone();
let image_link = if let Some(image_link) = state.direct_links.get(&art.url) { let image_link = if let Some(image_link) = state.direct_links.get(&art.url) {
image_link.to_string() image_link.clone()
} else { } else {
let image_link = match art.kind { let image_link_fn = match art.kind {
ArtKind::Twitter => fetch_twitter_image_link(&state.http, &art.url).await?, ArtKind::Twitter => fetch_twitter_image_link,
ArtKind::Safebooru => fetch_safebooru_image_link(&state.http, &art.url).await?, ArtKind::Safebooru => fetch_safebooru_image_link,
}; };
let image_link = (image_link_fn)(&state.http, &art.url).await?;
state state
.direct_links .direct_links
.insert(art.url.clone(), image_link.clone()); .insert(art.url.clone(), image_link.clone());
@ -79,15 +83,16 @@ async fn show_art(
} }
const BODY_STYLE: &str = const BODY_STYLE: &str =
"margin: 0px; background: #0e0e0e; height: 100vh; width: 100vw; display: flex; font-family: \"PT Mono\", monospace; font-weight: 400; font-style: normal; font-optical-sizing: auto;"; "color: #ffffff; margin: 0px; background: #0e0e0e; height: 100vh; width: 100vw; display: flex; font-family: \"PT Mono\", monospace; font-weight: 400; font-style: normal; font-optical-sizing: auto;";
const IMG_STYLE: &str = "display: block; margin: auto; max-height: 100vh; max-width: 100vw;";
const ABOUT_STYLE: &str = "position: absolute; bottom: 0; font-size: 0.75em; color: #ffffff; background-color: #0e0e0eaa;"; const ABOUT_STYLE: &str = "position: absolute; bottom: 0; font-size: 0.75em; color: #ffffff; background-color: #0e0e0eaa;";
fn get_page_head_common() -> PreEscaped<String> { fn get_page_head_common() -> PreEscaped<String> {
let title = get_conf("SITE_TITLE"); let title = get_conf("SITE_TITLE").unwrap_or_else(|| "random project moon art".to_string());
let embed_title = get_conf("EMBED_TITLE"); let embed_title =
let embed_content = get_conf("EMBED_DESC"); get_conf("EMBED_TITLE").unwrap_or_else(|| "random project moon art".to_string());
let embed_color = get_conf("EMBED_COLOR"); let embed_content =
get_conf("EMBED_DESC").unwrap_or_else(|| "random project moon art".to_string());
let embed_color = get_conf("EMBED_COLOR").unwrap_or_else(|| "#ffffff".to_string());
maud::html! { maud::html! {
meta charset="utf8"; meta charset="utf8";
@ -97,6 +102,7 @@ fn get_page_head_common() -> PreEscaped<String> {
link rel="preconnect" href="https://fonts.googleapis.com"; link rel="preconnect" href="https://fonts.googleapis.com";
link rel="preconnect" href="https://fonts.gstatic.com" crossorigin; link rel="preconnect" href="https://fonts.gstatic.com" crossorigin;
link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=PT+Mono&display=swap"; link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=PT+Mono&display=swap";
link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@chgibb/css-spinners@2.2.1/css/spinners.min.css";
title { (title) } title { (title) }
} }
} }
@ -111,16 +117,20 @@ fn get_page_contact() -> PreEscaped<String> {
} }
} }
fn render_page(art: &Art, image_link: &str) -> Html<String> { fn render_page(art: &Art, image_link: &FetchedLink) -> Html<String> {
let art_url = image_link.new_source.as_ref().unwrap_or(&art.url);
let content = maud::html! { let content = maud::html! {
(maud::DOCTYPE) (maud::DOCTYPE)
head { head {
(get_page_head_common()) (get_page_head_common())
} }
body style=(BODY_STYLE) { body style=(BODY_STYLE) {
img style=(IMG_STYLE) src=(image_link); div style="display: block; margin: auto; max-height: 98vh; max-width: 98vw;" {
a style=(format!("{ABOUT_STYLE} left: 0;")) href=(art.url) target="_blank" { div class="throbber-loader" style="position: absolute; top: 50%; left: 50%; z-index: -1;" {}
"source: " (art.url) img style="max-height: 98vh; max-width: 98vw;" src=(image_link.image_url);
}
a style=(format!("{ABOUT_STYLE} left: 0;")) href=(art_url) target="_blank" {
"source: " (art_url)
} }
(get_page_contact()) (get_page_contact())
} }
@ -128,7 +138,21 @@ fn render_page(art: &Art, image_link: &str) -> Html<String> {
Html(content.into_string()) Html(content.into_string())
} }
async fn fetch_safebooru_image_link(http: &reqwest::Client, url: &Uri) -> AppResult<String> { fn fetch_safebooru_image_link<'a>(
http: &'a reqwest::Client,
url: &'a Uri,
) -> BoxFuture<'a, AppResult<FetchedLink>> {
_fetch_safebooru_image_link(http, url).boxed()
}
fn fetch_twitter_image_link<'a>(
http: &'a reqwest::Client,
url: &'a Uri,
) -> BoxFuture<'a, AppResult<FetchedLink>> {
_fetch_twitter_image_link(http, url).boxed()
}
async fn _fetch_safebooru_image_link(http: &reqwest::Client, url: &Uri) -> AppResult<FetchedLink> {
let mut id = String::new(); let mut id = String::new();
for (name, value) in form_urlencoded::parse(url.query().unwrap().as_bytes()) { for (name, value) in form_urlencoded::parse(url.query().unwrap().as_bytes()) {
if name == "id" { if name == "id" {
@ -166,15 +190,85 @@ async fn fetch_safebooru_image_link(http: &reqwest::Client, url: &Uri) -> AppRes
.await .await
.map_err(|(e, _)| e)?; .map_err(|(e, _)| e)?;
let image_filename = data[0].get("image").unwrap().as_str().unwrap(); let source_url = data[0]
let image_directory = data[0].get("directory").unwrap().as_str().unwrap(); .get("source")
.and_then(|src| Uri::from_str(src.as_str()?).ok())
.map(|src| {
if src.host() == Some("i.pximg.net") {
let post_id = src
.path()
.split('/')
.last()
.unwrap()
.split("_")
.next()
.unwrap();
return Uri::builder()
.scheme("https")
.authority("pixiv.net")
.path_and_query(format!("/en/artworks/{post_id}"))
.build()
.unwrap();
} else {
src
}
});
Ok(format!( if source_url.as_ref().map_or(false, |src| {
"http://safebooru.org/images/{image_directory}/{image_filename}" src.host().unwrap().contains("twitter.com") || src.host().unwrap().contains("x.com")
)) }) {
let url = source_url.clone().unwrap();
println!("[safebooru] source was twitter, will try to fetch image from there instead");
if let Ok(mut fetched) = _fetch_twitter_image_link(http, &url).await {
println!("[safebooru] fetched image from twitter");
fetched.new_source = Some(url);
return Ok(fetched);
}
}
let sample_url = data[0]
.get("sample_url")
.ok_or("safebooru did not return sample url")?
.as_str()
.ok_or("safebooru sample url wasnt a string")?;
let sample_url = Uri::from_str(sample_url)
.map_err(|err| AppError::from(format!("safebooru sample url was not valid: {err}")))?;
let fsample_url = format!(
"{}://{}{}",
sample_url.scheme_str().unwrap(),
sample_url.host().unwrap(),
sample_url.path()
);
let ssample_url = format!(
"{}://{}/{}",
sample_url.scheme_str().unwrap(),
sample_url.host().unwrap(),
sample_url.path()
);
let fsample_resp = http
.execute(http.get(&fsample_url).build()?)
.await
.and_then(|resp| resp.error_for_status());
let ssample_resp = http
.execute(http.get(&ssample_url).build()?)
.await
.and_then(|resp| resp.error_for_status());
let sample_url = fsample_resp
.is_ok()
.then(|| fsample_url)
.or_else(|| ssample_resp.is_ok().then(|| ssample_url))
.unwrap_or_else(|| sample_url.to_string());
Ok(FetchedLink {
image_url: sample_url,
new_source: source_url,
})
} }
async fn fetch_twitter_image_link(http: &reqwest::Client, url: &Uri) -> AppResult<String> { async fn _fetch_twitter_image_link(http: &reqwest::Client, url: &Uri) -> AppResult<FetchedLink> {
let fxurl = Uri::builder() let fxurl = Uri::builder()
.scheme("https") .scheme("https")
.authority("d.fxtwitter.com") .authority("d.fxtwitter.com")
@ -190,16 +284,19 @@ async fn fetch_twitter_image_link(http: &reqwest::Client, url: &Uri) -> AppResul
.ok_or_else(|| format!("twitter link {fxurl} did not return an image location"))? .ok_or_else(|| format!("twitter link {fxurl} did not return an image location"))?
.to_str()?; .to_str()?;
// use webp format for direct twitter links since webp is cheaper // use webp format for direct twitter links since webp is cheaper
Ok(format!("{link}?format=webp")) Ok(FetchedLink {
image_url: format!("{link}?format=webp"),
new_source: None,
})
} }
fn get_conf(name: &str) -> String { fn get_conf(name: &str) -> Option<String> {
std::env::var(name).unwrap() std::env::var(name).ok()
} }
struct InternalAppState { struct InternalAppState {
// cached direct links to images // cached direct links to images
direct_links: DashMap<Uri, String>, direct_links: DashMap<Uri, FetchedLink>,
data: Mutex<Data>, data: Mutex<Data>,
http: reqwest::Client, http: reqwest::Client,
} }
@ -217,7 +314,11 @@ impl AppState {
direct_links: Default::default(), direct_links: Default::default(),
http: reqwest::ClientBuilder::new() http: reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none()) .redirect(reqwest::redirect::Policy::none())
.user_agent(format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))) .user_agent(format!(
"{}/{}",
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION")
))
.build() .build()
.unwrap(), .unwrap(),
}), }),