M Cargo.toml => Cargo.toml +2 -0
@@ 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"
M README.md => README.md +51 -8
@@ 11,26 11,30 @@ Once configured, a request of the form `http://localhost:9999/<last.fm username>
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 `<num><suffix>` 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
+
M src/cache.rs => src/cache.rs +8 -3
@@ 11,7 11,7 @@ impl<K, V, F, Fut> AsyncCache<K, V, F>
where
for<'a> F: FnMut(&'a K) -> Fut + 'a,
K: Hash + PartialEq + Eq + Clone,
- Fut: Future<Output = Result<V, (StatusCode, &'static str)>>
+ Fut: Future<Output = Result<V, (StatusCode, &'static str)>> + 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<K, V, F, Fut> AsyncCache<K, V, F>
@@ 55,7 60,7 @@ where
for<'a> F: FnMut(&'a K) -> Fut + 'a,
K: Hash + PartialEq + Eq + Clone,
V: Clone,
- Fut: Future<Output = Result<V, (StatusCode, &'static str)>>
+ Fut: Future<Output = Result<V, (StatusCode, &'static str)>> + Send + Sync
{
pub async fn get_owned(&mut self, key: &K) -> Result<V, (StatusCode, &'static str)> {
self.get(key).await.cloned()
M src/config.rs => src/config.rs +61 -35
@@ 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<Arc<State>> = LazyLock::new(|| {
State::new()
});
-fn getter(username: &String) -> Pin<Box<dyn Future<Output = Result<(User, Track), (StatusCode, &'static str)>>>> {
+fn getter(username: &String) -> Pin<Box<(dyn Future<Output = Result<Arc<(User, Track)>, (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<Box<dyn Future<Output = Result<(User, Track)
.map_err(|e| {log::error!("Couldn't parse user.getRecentTracks for `{username}`: {e}"); (StatusCode::INTERNAL_SERVER_ERROR, "Couldn't parse user.getRecentTracks!")})?
.recenttracks.track.into_iter().nth(0).ok_or((StatusCode::UNPROCESSABLE_ENTITY, "You need to listen to some songs first!"))?;
- Ok((userinfo, tracksinfo))
+ Ok(Arc::new((userinfo, tracksinfo)))
})
}
-type Getter = fn(&String) -> Pin<Box<(dyn Future<Output = Result<(User, Track), (StatusCode, &'static str)>>)>>;
-type Cache = Arc<RwLock<AsyncCache<String, (User, Track), Getter>>>;
+type Getter = fn(&String) -> Pin<Box<(dyn Future<Output = Result<Arc<(User, Track)>, (StatusCode, &'static str)>> + Send + Sync)>>;
+type Cache = Arc<RwLock<AsyncCache<String, Arc<(User, Track)>, Getter>>>;
#[derive(Debug)]
enum Whitelist {
Exclusive{cache: Cache, whitelist: HashSet<String>},
@@ 57,44 56,50 @@ pub struct State {
api_key: Arc<str>,
whitelist: Whitelist,
port: u16,
- themes: HashMap<String, Arc<str>>,
+ handlebars: Handlebars<'static>,
+ default_theme: Arc<str>,
send_refresh_header: bool,
- http: Client
+ http: Client,
+
+ default_refresh: Duration,
+ whitelist_refresh: Duration
}
impl State {
fn new() -> Arc<Self> {
+ 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<String, (User, Track), Getter>))
- };
- let default_cache = || cache_from_var("LFME_DEFAULT_REFRESH", 300);
- let whitelist_cache = || cache_from_var("LFME_WHITELIST_REFRESH", 60);
-
let load_whitelist = || -> Option<HashSet<String>> {
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<Arc<str>> { self.themes.get(theme).cloned() }
+ pub async fn get_userinfo(&self, user: &String) -> (Result<Arc<(User, Track)>, (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<str> { self.default_theme.clone() }
}
A src/ctx.rs => src/ctx.rs +122 -0
@@ 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<str>,
+ /// Their "Display Name".
+ pub realname: Arc<str>,
+
+ /// 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<str>,
+
+ /// Link to user's profile.
+ pub url: Arc<str>
+ }
+
+ /// The theme representation of an artist
+ #[derive(serde::Serialize, Debug)]
+ pub struct Artist {
+ /// The artist's name.
+ pub name: Arc<str>,
+
+ /// A link to their current image.
+ pub image_url: Arc<str>,
+ /// A link to their last.fm page.
+ pub url: Arc<str>
+ }
+
+ /// 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<str>,
+ /// The name of its album.
+ pub album: Arc<str>,
+ /// The artist who made it.
+ pub artist: Artist,
+
+ /// A link to the track image.
+ pub image_url: Arc<str>,
+ /// 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<str>,
+
+ /// 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<Result<Arc<(de::User, de::Track)>, (StatusCode, &'static str)>> for ResponseCtx {
+ fn from(v: Result<Arc<(de::User, de::Track)>, (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)
+ }
+ }
+ }
+}
M src/deserialize.rs => src/deserialize.rs +2 -1
@@ 78,8 78,9 @@ pub struct Artist {
pub url: Option<Arc<str>>
}
-#[derive(Deserialize, Debug)]
+#[derive(Deserialize, Debug, Ord, PartialOrd, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
+#[repr(u8)]
pub enum ImageSize {
Small,
Medium,
M src/lib.rs => src/lib.rs +2 -0
@@ 3,5 3,7 @@
pub mod deserialize;
pub mod cache;
pub mod config;
+pub mod ctx;
pub use config::STATE;
+pub use ctx::ResponseCtx;
M src/main.rs => src/main.rs +30 -3
@@ 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<Arc<str>>
+}
+
+#[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::<UserQuery>())
+ .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;
}