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. --- Cargo.toml | 2 + README.md | 59 ++++++++++++++++++++++---- src/cache.rs | 11 +++-- src/config.rs | 96 ++++++++++++++++++++++++++--------------- src/ctx.rs | 122 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/deserialize.rs | 3 +- src/lib.rs | 2 + src/main.rs | 33 +++++++++++++-- 8 files changed, 278 insertions(+), 50 deletions(-) create mode 100644 src/ctx.rs diff --git a/Cargo.toml b/Cargo.toml index ce0a0f1..da3ce4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,8 +13,10 @@ documentation = "https://git.aleteoryx.me/cgit/lfm_embed/about" dotenv = "0.15.0" duration-str = "0.5.1" env_logger = "0.10.0" +handlebars = { version = "4.3.7", features = ["dir_source"] } log = "0.4.19" reqwest = { version = "0.11.18", features = ["gzip", "deflate", "brotli", "json"] } serde = { version = "1.0.183", features = ["derive", "rc", "alloc"] } serde_json = "1.0.104" tokio = { version = "1.29.1", features = ["full"] } +warp = "0.3.5" diff --git a/README.md b/README.md index 974a711..97ea66c 100644 --- a/README.md +++ b/README.md @@ -11,26 +11,30 @@ Once configured, a request of the form `http://localhost:9999/ As it stands, there are no plans to support displaying users with private listen history. +*** + ## Configuration Configuration should be done via the environment. `lfm_embed` also supports reading from a standard `.env` file. The following are the environment variables which `lfm_embed` understands. +*** + ### `LMFE_API_KEY` (Required) Your last.fm API key. You'll need to create one [here](https://www.last.fm/api/account/create) for self-hosting. -### `LFME_WHITELIST_MODE` (Default: open) +### `LFME_WHITELIST_MODE` (Default: `"open"`) The following(case-insensitive) values are supported: - `open`: Allow requests for all users. - `exclusive`: Only allow requests for users in `LFME_WHITELIST`, returning HTTP 403 for all others. `LFME_WHITELIST` _must_ be set if this mode is enabled. If the user requested has their listen history private, a 403 will be returned. -### `LFME_WHITELIST` (Default: "") +### `LFME_WHITELIST` (Default: `""`) This is expected to be a sequence of comma-separated usernames. Leading/trailing whitespace will be stripped, and unicode is fully supported. -### `LFME_WHITELIST_REFRESH` (Default: "1m") +### `LFME_WHITELIST_REFRESH` (Default: `"1m"`) The amount of time to cache whitelisted user info for. It is interpreted as a sequence of `` values, where `num` is a positive integer, and `suffix` is one of `y`,`mon`,`w`,`d`,`h`,`m`,`s`, `ms`, `µs`, or `ns`, each of which @@ -38,25 +42,60 @@ corresponds to a self-explanatory unit of time. For most practical applications, one should only use `m` and `s`, as caching your current listen for more than that time has the potential to omit most songs. Parsing is delegated to the [`duration_str`](https://docs.rs/duration-str/latest/duration_str/) crate, and further info may be found there. -### `LFME_DEFAULT_REFRESH` (Default: "5m") +### `LFME_DEFAULT_REFRESH` (Default: `"5m"`) The amount of time to cache non-whitelisted user info for. See `LFME_WHITELIST_REFRESH` for more info. -### `LFME_PORT` (Default: 9999) +### `LFME_PORT` (Default: `9999`) The port to serve on locally. -### `LFME_THEMES_DIR` -If set, must be a valid path to a directory containing CSS files. +### `LFME_THEME_DIR` +If set, must be a valid path to a directory containing [Handlebars](https://handlebarsjs.com/guide/#language-features) files, ending in LFME_THEME_EXT. They will be registered as themes on top of the builtin ones, with each theme's name being their filename minus the extension. Same-named themes will override builtin ones. -### `LFME_LOG_LEVEL` (Default: Warn) +Theme names are the same as their path, minus the extension. Given an extension of .hbs, a directory like: +``` +themes/ + mytheme.hbs + myothertheme.hbs + myunrelatedfile.css + alices-themes/ + mytheme.hbs + mysuperawesometheme.hbs +``` +results in the following themes: +``` +mytheme +myothertheme +alices-themes/mytheme +alices-themes/mysuperawesometheme +``` + +By default, these are loaded and compiled once, at startup. + +### `LFME_THEME_EXT` (Default: `hbs`) +The file extension for themes in `LFME_THEME_DIR`. + +### `LFME_THEME_DEV` (Default: `0`) +If set to `1`, existing themes will be reloaded on edit. + +Note: Even with this mode, adding a new theme requires a full reload. +Themes are only enumerated once, at startup. (This is a limitation of the [`handlebars`](https://docs.rs/handlebars/latest/handlebars) implementation in use, and may change.) + +### `LFME_THEME_DEFAULT` (Default: `"plain"`) +The theme to use when no query string is present. + +### `LFME_LOG_LEVEL` (Default: `"warn"`) The loglevel. This is actually parsed as an [`env_logger`](https://docs.rs/env_logger/latest/env_logger) filter string. Read the docs for more info. ### `LFME_LOG_FILE` If set, logs will be written to the specified file. Otherwise, logs are written to `stderr`. +### `LFME_NO_REFRESH` (Default: `0`) +If set to `1`, disable outputting of the HTML `meta http-eqiv="refresh"` tag, used for live status updates. + ## Example Configuration ```ini LFME_API_KEY=0123456789abcdef0123456789abcdef @@ -67,3 +106,7 @@ LFME_WHITELIST_REFRESH=30s LFME_WHITELIST_MODE=exclusive LFME_WHITELIST=a_precious_basket_case, realRiversCuomo, Pixiesfan12345 ``` +*** + +## Theming + 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