From 4bec6679a8af2a2b5cb53610a80dece3b6d30bb4 Mon Sep 17 00:00:00 2001 From: alyx Date: Thu, 10 Aug 2023 03:10:54 -0400 Subject: lots of stuff. theming basically done, added some logging, frontend basically done. --- src/cache.rs | 11 +++-- src/config.rs | 96 ++++++++++++++++++++++++++--------------- src/ctx.rs | 122 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/deserialize.rs | 3 +- src/lib.rs | 2 + src/main.rs | 33 +++++++++++++-- 6 files changed, 225 insertions(+), 42 deletions(-) create mode 100644 src/ctx.rs (limited to 'src') diff --git a/src/cache.rs b/src/cache.rs index dfe23e7..631f900 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -11,7 +11,7 @@ impl AsyncCache where for<'a> F: FnMut(&'a K) -> Fut + 'a, K: Hash + PartialEq + Eq + Clone, - Fut: Future> + Fut: Future> + Send + Sync { pub fn new(interval: Duration, func: F) -> Self { Self{ @@ -22,8 +22,10 @@ where pub async fn get(&mut self, key: &K) -> Result<&V, (StatusCode, &'static str)> { if self.is_stale(&key) { + log::trace!(target: "lfm::cache", "MISS : interval = {:?}", self.interval); self.renew(&key).await } else { + log::trace!(target: "lfm::cache", "HIT : interval = {:?}", self.interval); Ok(&self.cache.get(&key).unwrap().1) } } @@ -37,7 +39,8 @@ where pub fn is_stale(&self, key: &K) -> bool { if let Some((last_update, _)) = self.cache.get(key) { let now = Instant::now(); - now < (*last_update + self.interval) + log::trace!(target: "lfm::cache", "Key exists, last update {:?} ago.", now - *last_update); + now > (*last_update + self.interval) } else { true } } @@ -48,6 +51,8 @@ where } else { None } } + + pub fn interval(&self) -> Duration { self.interval } } impl AsyncCache @@ -55,7 +60,7 @@ where for<'a> F: FnMut(&'a K) -> Fut + 'a, K: Hash + PartialEq + Eq + Clone, V: Clone, - Fut: Future> + Fut: Future> + Send + Sync { pub async fn get_owned(&mut self, key: &K) -> Result { self.get(key).await.cloned() diff --git a/src/config.rs b/src/config.rs index bf02592..aad298f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,12 +1,9 @@ use std::collections::{HashMap, HashSet}; -use std::error::Error; use std::sync::LazyLock; use std::sync::Arc; use std::future::Future; use std::pin::Pin; -use std::fs; use std::time::*; -use duration_str as ds; use super::cache::AsyncCache; use super::deserialize::{GetRecentTracks, GetUserInfo, Track, User}; @@ -14,6 +11,8 @@ use super::deserialize::{GetRecentTracks, GetUserInfo, Track, User}; use reqwest::{Client, StatusCode}; use dotenv::var; use tokio::sync::RwLock; +use handlebars::Handlebars; +use duration_str as ds; static INTERNAL_THEMES: &[(&'static str, &'static str)] = &[("plain", "")]; @@ -21,7 +20,7 @@ pub static STATE: LazyLock> = LazyLock::new(|| { State::new() }); -fn getter(username: &String) -> Pin>>> { +fn getter(username: &String) -> Pin, (StatusCode, &'static str)>> + Send + Sync)>> { let username = username.clone(); Box::pin(async move { let userreq = STATE.http.get(format!("https://ws.audioscrobbler.com/2.0/?method=user.getInfo&format=json&user={username}&api_key={}", STATE.api_key)) @@ -42,11 +41,11 @@ fn getter(username: &String) -> Pin Pin>)>>; -type Cache = Arc>>; +type Getter = fn(&String) -> Pin, (StatusCode, &'static str)>> + Send + Sync)>>; +type Cache = Arc, Getter>>>; #[derive(Debug)] enum Whitelist { Exclusive{cache: Cache, whitelist: HashSet}, @@ -57,44 +56,50 @@ pub struct State { api_key: Arc, whitelist: Whitelist, port: u16, - themes: HashMap>, + handlebars: Handlebars<'static>, + default_theme: Arc, send_refresh_header: bool, - http: Client + http: Client, + + default_refresh: Duration, + whitelist_refresh: Duration } impl State { fn new() -> Arc { + let duration_from_var = |v: &str, d: u64| -> Duration {var(v).map(|r| ds::parse(&r).expect("bad duration string")).unwrap_or_else(|_| Duration::from_secs(d))}; + let cache_from_duration = |d: Duration| -> Cache { + Arc::new(RwLock::new(AsyncCache::new(d, getter as Getter))) + }; + let default_refresh = duration_from_var("LFME_DEFAULT_REFRESH", 300); + let whitelist_refresh = duration_from_var("LFME_WHITELIST_REFRESH", 60); + let default_cache = cache_from_duration(default_refresh); + let whitelist_cache = cache_from_duration(whitelist_refresh); + Arc::new(State { api_key: var("LFME_API_KEY").expect("API key must be set").into(), port: var("LFME_PORT").map(|p| p.parse().expect("cannot parse as a port number")).unwrap_or(9999), - send_refresh_header: var("LFME_SET_HEADER").map(|h| &h == "1").unwrap_or(false), + send_refresh_header: !var("LFME_NO_REFRESH").map(|h| &h == "1").unwrap_or(false), http: Client::builder().https_only(true).user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))).build().unwrap(), - themes: { - if let Ok(themes_dir) = var("LFME_THEMES_DIR") { - INTERNAL_THEMES.iter().map(|(k, v)| (k.to_string(), (*v).into())).chain(fs::read_dir(themes_dir).expect("error reading LFME_THEMES_DIR") - .map(|a| a.expect("error reading LFME_THEMES_DIR")) - .filter_map(|a| { - let path = a.path(); - if fs::metadata(&path).map(|m| m.is_file()).unwrap_or(false) && - path.extension() == Some("css".as_ref()) { - Some((path.file_stem().unwrap().to_str().expect("bad filename").to_string(), fs::read_to_string(&path).expect("couldn't read theme CSS").into())) - } - else { None } - })) - .collect() + handlebars: { + let mut hb = Handlebars::new(); + for (key, fulltext) in INTERNAL_THEMES { + log::info!(target: "lfm::config::theme", "Registering internal theme `{key}`"); + hb.register_template_string(key, fulltext).unwrap(); } - else { HashMap::new() } + hb.set_dev_mode(var("LFME_THEME_DEV").map(|h| &h == "1").unwrap_or(false)); + + if let Ok(themes_dir) = var("LFME_THEME_DIR") { + log::info!(target: "lfm::config::theme", "Registering theme dir `{themes_dir}`"); + hb.register_templates_directory(&var("LFME_THEME_EXT").unwrap_or_else(|_| "hbs".into()), themes_dir).unwrap(); + } + + hb }, + default_theme: var("LFME_THEME_DEFAULT").map(Into::into).unwrap_or_else(|_| "plain".into()), whitelist: { - let cache_from_var = |v: &str, d: u64| -> Cache { - let refresh = var(v).map(|r| ds::parse(&r).expect("bad duration string")).unwrap_or_else(|_| Duration::from_secs(d)); - Arc::new(RwLock::new(AsyncCache::new(refresh, getter as Getter) as AsyncCache)) - }; - let default_cache = || cache_from_var("LFME_DEFAULT_REFRESH", 300); - let whitelist_cache = || cache_from_var("LFME_WHITELIST_REFRESH", 60); - let load_whitelist = || -> Option> { var("LFME_WHITELIST").ok().map( |w| w.split(",").map(|s| s.trim().to_string()).collect() @@ -103,20 +108,41 @@ impl State { match var("LFME_WHITELIST_MODE").map(|m| m.to_ascii_lowercase()).unwrap_or_else(|_| "open".into()).as_str() { "open" => { - Whitelist::Open{default_cache: default_cache(), whitelist_cache: whitelist_cache(), whitelist: load_whitelist().unwrap_or_default()} + Whitelist::Open{default_cache, whitelist_cache, whitelist: load_whitelist().unwrap_or_default()} }, "exclusive" => { - Whitelist::Exclusive{cache: whitelist_cache(), whitelist: load_whitelist().expect("LFME_WHITELIST not set, unable to serve anyone")} + Whitelist::Exclusive{cache: whitelist_cache, whitelist: load_whitelist().expect("LFME_WHITELIST not set, unable to serve anyone")} }, m => { panic!("Bad whitelist mode: `{m}`"); } } - } + }, + default_refresh: default_refresh + Duration::from_secs(5), + whitelist_refresh: whitelist_refresh + Duration::from_secs(5) }) } pub fn port(&self) -> u16 { self.port } pub fn send_refresh_header(&self) -> bool { self.send_refresh_header } - pub fn get_theme(&self, theme: &str) -> Option> { self.themes.get(theme).cloned() } + pub async fn get_userinfo(&self, user: &String) -> (Result, (StatusCode, &'static str)>, Duration) { + match &self.whitelist { + Whitelist::Open{default_cache, whitelist_cache, whitelist} => { + if whitelist.contains(user) { + (whitelist_cache.write().await.get_owned(user).await, self.whitelist_refresh) + } + else { + (default_cache.write().await.get_owned(user).await, self.default_refresh) + } + }, + Whitelist::Exclusive{cache, whitelist} => { + if whitelist.contains(user) { + (cache.write().await.get_owned(user).await, self.whitelist_refresh) + } + else { (Err((StatusCode::FORBIDDEN, "User not in whitelist!")), self.default_refresh) } + } + } + } + pub fn handlebars(&self) -> &Handlebars { &self.handlebars } + pub fn default_theme(&self) -> Arc { self.default_theme.clone() } } diff --git a/src/ctx.rs b/src/ctx.rs new file mode 100644 index 0000000..6beb634 --- /dev/null +++ b/src/ctx.rs @@ -0,0 +1,122 @@ +use reqwest::StatusCode; +use super::deserialize as de; +use std::sync::Arc; + +pub mod model { + use std::sync::Arc; + + /// The theme representation of a user. + #[derive(serde::Serialize, Debug)] + pub struct User { + /// Their username. + pub name: Arc, + /// Their "Display Name". + pub realname: Arc, + + /// True if user subscribes to last.fm pro. + pub pro_subscriber: bool, + /// Total scrobbles. + pub scrobble_count: u64, + /// Number of artists in library. + pub artist_count: u64, + /// Number of tracks in library. + pub track_count: u64, + /// Number of albums in library. + pub album_count: u64, + + /// Link to user's profile picture. + pub image_url: Arc, + + /// Link to user's profile. + pub url: Arc + } + + /// The theme representation of an artist + #[derive(serde::Serialize, Debug)] + pub struct Artist { + /// The artist's name. + pub name: Arc, + + /// A link to their current image. + pub image_url: Arc, + /// A link to their last.fm page. + pub url: Arc + } + + /// The theme representation of a user's most recently scrobbled track. + #[derive(serde::Serialize, Debug)] + pub struct Scrobble { + /// The name of the track. + pub name: Arc, + /// The name of its album. + pub album: Arc, + /// The artist who made it. + pub artist: Artist, + + /// A link to the track image. + pub image_url: Arc, + /// True if the user is currently scrobbling it, false if it's just the most recently played track. + pub now_playing: bool, + /// A link to the track's last.fm page. + pub url: Arc, + + /// True if the user has loved the track. + pub loved: bool + } + + /// The context passed in to all themes. + /// + /// Serialized as untagged, so themes should check if `error` is set and decide whether to show an error state or try rendering user info. + #[derive(serde::Serialize, Debug)] + #[serde(untagged)] + pub enum Data { + /// Contains text explaining a potential error. + Error { error: &'static str }, + /// Contains data about a user and what they're listening to. + Data { user: User, scrobble: Scrobble } + } +} +#[derive(Debug)] +pub struct ResponseCtx(pub model::Data, pub StatusCode); + +impl From, (StatusCode, &'static str)>> for ResponseCtx { + fn from(v: Result, (StatusCode, &'static str)>) -> ResponseCtx { + match v { + Ok(a) => { + let (user, track) = a.as_ref(); + ResponseCtx(model::Data::Data { + user: model::User { + name: user.name.clone(), + realname: user.realname.clone(), + + pro_subscriber: user.subscriber, + scrobble_count: user.playcount, + artist_count: user.artist_count, + track_count: user.track_count, + album_count: user.track_count, + + image_url: user.images.iter().max_by(|a, b| a.size.cmp(&b.size)).map(|a| a.url.clone()).unwrap_or_else(|| "".into()), + + url: user.url.clone() + }, + scrobble: model::Scrobble { + name: track.name.clone(), + album: track.album.name.clone(), + artist: model::Artist { + name: track.artist.name.clone(), + image_url: track.artist.images.iter().max_by(|a, b| a.size.cmp(&b.size)).map(|a| a.url.clone()).unwrap_or_else(|| "".into()), + url: track.artist.url.clone().unwrap_or_else(|| "".into()) + }, + image_url: track.images.iter().max_by(|a, b| a.size.cmp(&b.size)).map(|a| a.url.clone()).unwrap_or_else(|| "".into()), + now_playing: track.attr.nowplaying, + url: track.url.clone(), + loved: track.loved.unwrap_or(false) + } + }, StatusCode::OK) + }, + Err((status, error)) => { + ResponseCtx(model::Data::Error {error}, status) + } + } + } +} diff --git a/src/deserialize.rs b/src/deserialize.rs index a00ebcb..ad67df7 100644 --- a/src/deserialize.rs +++ b/src/deserialize.rs @@ -78,8 +78,9 @@ pub struct Artist { pub url: Option> } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Debug, Ord, PartialOrd, Eq, PartialEq)] #[serde(rename_all = "lowercase")] +#[repr(u8)] pub enum ImageSize { Small, Medium, diff --git a/src/lib.rs b/src/lib.rs index 70522c0..0d1d299 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,5 +3,7 @@ pub mod deserialize; pub mod cache; pub mod config; +pub mod ctx; pub use config::STATE; +pub use ctx::ResponseCtx; diff --git a/src/main.rs b/src/main.rs index 13a9b7f..729482f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,9 +3,18 @@ use dotenv::var; use log::LevelFilter; use std::fs::File; -use lfm_embed::STATE; +use std::sync::Arc; +use lfm_embed::{STATE, ResponseCtx}; +use warp::Filter; -fn main() { +#[derive(serde::Deserialize, Debug)] +struct UserQuery { + #[serde(default)] + theme: Option> +} + +#[tokio::main] +async fn main() { env_logger::Builder::new() .filter_level(LevelFilter::Warn) .parse_filters(&var("LFME_LOG_LEVEL").unwrap_or_default()) @@ -23,5 +32,23 @@ fn main() { std::sync::LazyLock::force(&STATE); - + let user = warp::path!("user" / String) + .and(warp::query::()) + .then(|s, q: UserQuery| async move { + log::info!(target: "lfm::server::user", "Handling request for user `{s}` with {q:?}"); + let (ctx, dur) = STATE.get_userinfo(&s).await; + let ResponseCtx(data, status) = ctx.into(); + + let theme = q.theme.unwrap_or_else(|| STATE.default_theme()); + warp::reply::with_header( + warp::reply::with_status( + warp::reply::html( + STATE.handlebars().render(&theme, &data).unwrap() + ), status + ), "Refresh", dur.as_secs() + ) + }); + + warp::serve(user) + .bind(([127,0,0,1], STATE.port())).await; } -- cgit v1.2.3-54-g00ecf