M src/cache.rs => src/cache.rs +45 -103
@@ 2,125 2,67 @@ use std::{future::Future, time::*, collections::HashMap, hash::Hash};
use reqwest::StatusCode;
#[derive(Debug)]
pub struct AsyncCache<K, V, F> {
- func: F,
- cache: HashMap<K, (Instant, V)>,
- interval: Duration
+ func: F,
+ cache: HashMap<K, (Instant, V)>,
+ interval: Duration
}
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)>> + Send + Sync
+ for<'a> F: FnMut(&'a K) -> Fut + 'a,
+ K: Hash + PartialEq + Eq + Clone,
+ Fut: Future<Output = Result<V, (StatusCode, &'static str)>> + Send + Sync
{
- pub fn new(interval: Duration, func: F) -> Self {
- Self{
- cache: HashMap::new(),
- interval, func
- }
- }
-
- 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)
- }
- }
-
- pub async fn renew(&mut self, key: &K) -> Result<&V, (StatusCode, &'static str)> {
- let val = (self.func)(&key).await?;
- self.cache.insert(key.clone(), (Instant::now(), val));
- Ok(&self.cache.get(key).unwrap().1)
+ pub fn new(interval: Duration, func: F) -> Self {
+ Self{
+ cache: HashMap::new(),
+ interval, func
}
+ }
- pub fn is_stale(&self, key: &K) -> bool {
- if let Some((last_update, _)) = self.cache.get(key) {
- let now = Instant::now();
- log::trace!(target: "lfm::cache", "Key exists, last update {:?} ago.", now - *last_update);
- now > (*last_update + self.interval)
- }
- else { true }
- }
-
- pub async fn get_opt(&self, key: &K) -> Option<&V> {
- if self.is_stale(key) {
- self.cache.get(key).map(|(_, v)| v)
- }
- else { None }
+ 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)
}
+ }
- pub fn interval(&self) -> Duration { self.interval }
-}
+ pub async fn renew(&mut self, key: &K) -> Result<&V, (StatusCode, &'static str)> {
+ let val = (self.func)(&key).await?;
+ self.cache.insert(key.clone(), (Instant::now(), val));
+ Ok(&self.cache.get(key).unwrap().1)
+ }
-impl<K, V, F, Fut> AsyncCache<K, V, F>
-where
- for<'a> F: FnMut(&'a K) -> Fut + 'a,
- K: Hash + PartialEq + Eq + Clone,
- V: Clone,
- 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()
+ pub fn is_stale(&self, key: &K) -> bool {
+ if let Some((last_update, _)) = self.cache.get(key) {
+ let now = Instant::now();
+ log::trace!(target: "lfm::cache", "Key exists, last update {:?} ago.", now - *last_update);
+ now > (*last_update + self.interval)
}
-}
-/*
-pub struct AsyncCache<K, V, F> {
- func: F,
- cache: HashMap<K, (Instant, V)>,
- interval: Duration
-}
+ else { true }
+ }
-impl<K, V, F> AsyncCache<K, V, F>
-where
- for<'a> F: FnMut(&'a K) -> Fut + 'a,
- Fut: Future<Output = V>
-{
- pub fn new(interval: Duration, mut func: F) -> Self {
- Self{
- cache: HashMap::new(),
- interval, func
- }
- }
-
- pub async fn get(&mut self, key: &K) -> &V {
- if self.is_stale(key) {
- self.renew().await
- } else {
- self.cache.get(key)
- }
- }
-
- pub async fn renew(&mut self, key: &K) -> &V {
- self.cache.get_mut(key).0 = now;
- self.cache.get_mut(key).1 = (self.func)(key).await;
- self.cache.get(key)
+ pub async fn get_opt(&self, key: &K) -> Option<&V> {
+ if self.is_stale(key) {
+ self.cache.get(key).map(|(_, v)| v)
}
+ else { None }
+ }
- pub fn is_stale(&self, key: &K) -> bool {
- let now = Instant::now();
- let last_update = self.cache.get(key).0;
- now < (last_update + self.interval)
- }
-
- pub fn get_opt(&self, key: &K) -> Option<&T> {
- if self.is_stale(key) {
- Some(self.cache.get(key))
- }
- else { None }
- }
+ pub fn interval(&self) -> Duration { self.interval }
}
-impl<K, V, F> AsyncCache<K, V, F>
+impl<K, V, F, Fut> AsyncCache<K, V, F>
where
- F: for<'a> FnMut(&'a K) -> Fut + 'a,
- Fut: Future<Output = V>,
- V: Clone
+ for<'a> F: FnMut(&'a K) -> Fut + 'a,
+ K: Hash + PartialEq + Eq + Clone,
+ V: Clone,
+ Fut: Future<Output = Result<V, (StatusCode, &'static str)>> + Send + Sync
{
- pub async fn get_owned(&mut self, key: &K) -> V {
- self.get(key).await.clone()
- }
+ 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 +124 -124
@@ 1,4 1,4 @@
-use std::collections::{HashMap, HashSet};
+use std::collections::BTreeSet;
use std::sync::LazyLock;
use std::sync::Arc;
use std::future::Future;
@@ 17,32 17,32 @@ use duration_str as ds;
static INTERNAL_THEMES: &[(&'static str, &'static str)] = &[("plain", include_str!("themes/plain.hbs"))];
pub static STATE: LazyLock<Arc<State>> = LazyLock::new(|| {
- State::new()
+ State::new()
});
fn user_getter(username: &String) -> Pin<Box<(dyn Future<Output = Result<Arc<(User, Track)>, (StatusCode, &'static str)>> + Send + Sync)>> {
- let username = urlencoding::encode(username.as_ref()).to_string();
- 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.lastfm_api_key))
- .send().await
- .map_err(|e| {log::error!("Failed to get info for user `{username}`: {e}"); (StatusCode::SERVICE_UNAVAILABLE, "Couldn't connect to last.fm!")})?;
- if userreq.status() == StatusCode::NOT_FOUND { return Err((StatusCode::NOT_FOUND, "User does not exist!")); }
-
- let userinfo = userreq.json::<GetUserInfo>().await
- .map_err(|e| {log::error!("Couldn't parse user.getInfo for `{username}`: {e}"); (StatusCode::INTERNAL_SERVER_ERROR, "Couldn't parse user.getInfo!")})?.user;
-
- let tracksreq = STATE.http.get(format!("https://ws.audioscrobbler.com/2.0/?method=user.getRecentTracks&format=json&extended=1&limit=1&user={username}&api_key={}", STATE.lastfm_api_key))
- .send().await
- .map_err(|e| {log::error!("Failed to get tracks for user `{username}`: {e}"); (StatusCode::SERVICE_UNAVAILABLE, "Couldn't connect to last.fm!")})?;
- if tracksreq.status() == StatusCode::NOT_FOUND { return Err((StatusCode::NOT_FOUND, "User does not exist!")); }
- if tracksreq.status() == StatusCode::FORBIDDEN { return Err((StatusCode::FORBIDDEN, "You need to unprivate your song history!")); }
-
- let tracksinfo = tracksreq.json::<GetRecentTracks>().await
- .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(Arc::new((userinfo, tracksinfo)))
- })
+ let username = urlencoding::encode(username.as_ref()).to_string();
+ 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.lastfm_api_key))
+ .send().await
+ .map_err(|e| {log::error!("Failed to get info for user `{username}`: {e}"); (StatusCode::SERVICE_UNAVAILABLE, "Couldn't connect to last.fm!")})?;
+ if userreq.status() == StatusCode::NOT_FOUND { return Err((StatusCode::NOT_FOUND, "User does not exist!")); }
+
+ let userinfo = userreq.json::<GetUserInfo>().await
+ .map_err(|e| {log::error!("Couldn't parse user.getInfo for `{username}`: {e}"); (StatusCode::INTERNAL_SERVER_ERROR, "Couldn't parse user.getInfo!")})?.user;
+
+ let tracksreq = STATE.http.get(format!("https://ws.audioscrobbler.com/2.0/?method=user.getRecentTracks&format=json&extended=1&limit=1&user={username}&api_key={}", STATE.lastfm_api_key))
+ .send().await
+ .map_err(|e| {log::error!("Failed to get tracks for user `{username}`: {e}"); (StatusCode::SERVICE_UNAVAILABLE, "Couldn't connect to last.fm!")})?;
+ if tracksreq.status() == StatusCode::NOT_FOUND { return Err((StatusCode::NOT_FOUND, "User does not exist!")); }
+ if tracksreq.status() == StatusCode::FORBIDDEN { return Err((StatusCode::FORBIDDEN, "You need to unprivate your song history!")); }
+
+ let tracksinfo = tracksreq.json::<GetRecentTracks>().await
+ .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(Arc::new((userinfo, tracksinfo)))
+ })
}
fn font_getter(fontname: &String) -> Pin<Box<(dyn Future<Output = Result<Arc<str>, (StatusCode, &'static str)>> + Send + Sync)>> {
@@ 76,117 76,117 @@ type FontGetter = fn(&String) -> Pin<Box<(dyn Future<Output = Result<Arc<str>, (
type FontCache = Arc<RwLock<AsyncCache<String, Arc<str>, FontGetter>>>;
#[derive(Debug)]
enum Whitelist {
- Exclusive{cache: UserCache, whitelist: HashSet<String>},
- Open{default_cache: UserCache, whitelist_cache: UserCache, whitelist: HashSet<String>}
+ Exclusive{cache: UserCache, whitelist: BTreeSet<String>},
+ Open{default_cache: UserCache, whitelist_cache: UserCache, whitelist: BTreeSet<String>}
}
#[derive(Debug)]
pub struct State {
- lastfm_api_key: Arc<str>,
- google_api_key: Option<Arc<str>>,
- whitelist: Whitelist,
- port: u16,
+ lastfm_api_key: Arc<str>,
+ port: u16,
+ default_theme: Arc<str>,
+ send_refresh_header: bool,
- handlebars: Handlebars<'static>,
- default_theme: Arc<str>,
- send_refresh_header: bool,
- http: Client,
+ http: Client,
- google_fonts_cache: FontCache,
+ handlebars: Handlebars<'static>,
- default_refresh: Duration,
- whitelist_refresh: Duration,
+ google_api_key: Option<Arc<str>>,
+ google_fonts_cache: FontCache,
+
+ whitelist: Whitelist,
+ 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 user_cache_from_duration = |d: Duration| -> UserCache {
- Arc::new(RwLock::new(AsyncCache::new(d, user_getter as UserGetter)))
+ 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 user_cache_from_duration = |d: Duration| -> UserCache {
+ Arc::new(RwLock::new(AsyncCache::new(d, user_getter as UserGetter)))
+ };
+ let default_refresh = duration_from_var("LFME_DEFAULT_REFRESH", 300);
+ let whitelist_refresh = duration_from_var("LFME_WHITELIST_REFRESH", 60);
+ let default_cache = user_cache_from_duration(default_refresh);
+ let whitelist_cache = user_cache_from_duration(whitelist_refresh);
+ Arc::new(State {
+ lastfm_api_key: var("LFME_LASTFM_API_KEY").expect("last.fm API key must be set").into(),
+ port: var("LFME_PORT").map(|p| p.parse().expect("cannot parse as a port number")).unwrap_or(9999),
+ default_theme: var("LFME_THEME_DEFAULT").map(Into::into).unwrap_or_else(|_| "plain".into()),
+ 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(),
+
+ handlebars: {
+ let mut hb = Handlebars::new();
+ handlebars_helper!(url_encode: |s: String| urlencoding::encode(&s));
+
+ hb.register_helper("url-encode", Box::new(url_encode));
+
+ for (key, fulltext) in INTERNAL_THEMES {
+ log::info!(target: "lfm::config::theme", "Registering internal theme `{key}`");
+ hb.register_template_string(key, fulltext).unwrap();
+ }
+ 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
+ },
+
+ google_api_key: var("LFME_GOOGLE_API_KEY").map(Into::into).ok(),
+ google_fonts_cache: Arc::new(RwLock::new(AsyncCache::new(Duration::from_secs(86400), font_getter as FontGetter))),
+
+ whitelist: {
+ let load_whitelist = || -> Option<BTreeSet<String>> {
+ var("LFME_WHITELIST").ok().map(
+ |w| w.split(",").map(|s| s.trim().to_string()).collect()
+ )
};
- let default_refresh = duration_from_var("LFME_DEFAULT_REFRESH", 300);
- let whitelist_refresh = duration_from_var("LFME_WHITELIST_REFRESH", 60);
- let default_cache = user_cache_from_duration(default_refresh);
- let whitelist_cache = user_cache_from_duration(whitelist_refresh);
-
- Arc::new(State {
- lastfm_api_key: var("LFME_LASTFM_API_KEY").expect("last.fm 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_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(),
-
- handlebars: {
- let mut hb = Handlebars::new();
-
- handlebars_helper!(url_encode: |s: String| urlencoding::encode(&s));
-
- hb.register_helper("url-encode", Box::new(url_encode));
-
- for (key, fulltext) in INTERNAL_THEMES {
- log::info!(target: "lfm::config::theme", "Registering internal theme `{key}`");
- hb.register_template_string(key, fulltext).unwrap();
- }
- 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()),
-
- google_api_key: var("LFME_GOOGLE_API_KEY").map(Into::into).ok(),
- google_fonts_cache: Arc::new(RwLock::new(AsyncCache::new(Duration::from_secs(86400), font_getter as FontGetter))),
-
- whitelist: {
- let load_whitelist = || -> Option<HashSet<String>> {
- var("LFME_WHITELIST").ok().map(
- |w| w.split(",").map(|s| s.trim().to_string()).collect()
- )
- };
-
- match var("LFME_WHITELIST_MODE").map(|m| m.to_ascii_lowercase()).unwrap_or_else(|_| "open".into()).as_str() {
- "open" => {
- 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")}
- },
- m => {
- panic!("Bad whitelist mode: `{m}`");
- }
- }
- },
- default_refresh: default_refresh + Duration::from_secs(1),
- whitelist_refresh: whitelist_refresh + Duration::from_secs(1)
- })
- }
- pub fn port(&self) -> u16 { self.port }
- pub fn send_refresh_header(&self) -> bool { self.send_refresh_header }
- pub async fn get_fontinfo(&self, font: &String) -> Result<Arc<str>, (StatusCode, &'static str)> {
- self.google_fonts_cache.write().await.get_owned(font).await
- }
- pub fn has_google_api_key(&self) -> bool { self.google_api_key.is_some() }
- 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) }
- }
+ match var("LFME_WHITELIST_MODE").map(|m| m.to_ascii_lowercase()).unwrap_or_else(|_| "open".into()).as_str() {
+ "open" => {
+ 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")}
+ },
+ m => {
+ panic!("Bad whitelist mode: `{m}`");
+ }
+ }
+ },
+ default_refresh: default_refresh + Duration::from_secs(1),
+ whitelist_refresh: whitelist_refresh + Duration::from_secs(1)
+ })
+ }
+
+ pub fn port(&self) -> u16 { self.port }
+ pub fn send_refresh_header(&self) -> bool { self.send_refresh_header }
+ pub async fn get_fontinfo(&self, font: &String) -> Result<Arc<str>, (StatusCode, &'static str)> {
+ self.google_fonts_cache.write().await.get_owned(font).await
+ }
+ pub fn has_google_api_key(&self) -> bool { self.google_api_key.is_some() }
+ 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() }
+ }
+ pub fn handlebars(&self) -> &Handlebars { &self.handlebars }
+ pub fn default_theme(&self) -> Arc<str> { self.default_theme.clone() }
}
M src/ctx.rs => src/ctx.rs +137 -137
@@ 6,151 6,151 @@ use super::font::FontQuery;
use super::config::STATE;
pub mod model {
- use std::sync::Arc;
- use std::collections::BTreeMap;
-
- /// 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>
- }
+ use std::sync::Arc;
+ use std::collections::BTreeMap;
- /// 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 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>,
- #[derive(serde::Serialize, Debug)]
- #[serde(untagged)]
- pub enum Font {
- External { css: Arc<str>, name: Arc<str> },
- Name { name: Arc<str> },
- }
+ /// 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,
- /// 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, font: Option<Font>, query: BTreeMap<String, String>, }
- }
+ /// 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 user-specified font request parameters
+ #[derive(serde::Serialize, Debug)]
+ #[serde(untagged)]
+ pub enum Font {
+ /// A font that requires additional CSS to load properly.
+ External { css: Arc<str>, name: Arc<str> },
+ /// A font that is w3c standard, or widely installed.
+ Name { name: Arc<str> },
+ }
+
+ /// 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, font: Option<Font>, query: BTreeMap<String, String>, }
+ }
}
#[derive(Debug)]
pub struct ResponseCtx(pub model::Data, pub StatusCode);
impl ResponseCtx {
- pub async fn create(api_result: Result<Arc<(de::User, de::Track)>, (StatusCode, &'static str)>, font_query: Option<FontQuery>, query: BTreeMap<String, String>) -> ResponseCtx {
- match api_result {
- 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)
- },
- font: match font_query {
- Some(FontQuery { google_font: Some(f), .. }) if STATE.has_google_api_key() => {
- let css = match STATE.get_fontinfo(&f.to_string()).await {
- Ok(css) => css,
- Err((status, error)) => { return ResponseCtx(model::Data::Error {error}, status); }
- };
- Some(model::Font::External {
- css,
- name: f
- })
- },
- Some(FontQuery { include_font: Some(f), .. }) => Some(
- model::Font::External {
- css: format!(
- "@font-face {{ font-family: 'included_font'; src: url('{}'); }}",
- f.replace("\\", "\\\\")
- .replace("'", "\\'")).into(),
- name: "included_font".into()
- }),
- Some(FontQuery { font: Some(s), .. }) => Some(model::Font::Name { name: s }),
- _ => None,
- },
- query
- }, StatusCode::OK)
+ pub async fn create(api_result: Result<Arc<(de::User, de::Track)>, (StatusCode, &'static str)>, font_query: Option<FontQuery>, query: BTreeMap<String, String>) -> ResponseCtx {
+ match api_result {
+ Ok(a) => {
+ let (user, track) = a.as_ref();
+ ResponseCtx(model::Data::Data {
+ user: model::User {
+ name: user.name.clone(),
+ realname: user.realname.clone(),
+
+ 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)
+ },
+ font: match font_query {
+ Some(FontQuery { google_font: Some(f), .. }) if STATE.has_google_api_key() => {
+ let css = match STATE.get_fontinfo(&f.to_string()).await {
+ Ok(css) => css,
+ Err((status, error)) => { return ResponseCtx(model::Data::Error {error}, status); }
+ };
+ Some(model::Font::External {
+ css,
+ name: f
+ })
},
- Err((status, error)) => {
- ResponseCtx(model::Data::Error {error}, status)
- }
- }
+ Some(FontQuery { include_font: Some(f), .. }) => Some(
+ model::Font::External {
+ css: format!(
+ "@font-face {{ font-family: 'included_font'; src: url('{}'); }}",
+ f.replace("\\", "\\\\")
+ .replace("'", "\\'")).into(),
+ name: "included_font".into()
+ }),
+ Some(FontQuery { font: Some(s), .. }) => Some(model::Font::Name { name: s }),
+ _ => None,
+ },
+ query
+ }, StatusCode::OK)
+ },
+ Err((status, error)) => {
+ ResponseCtx(model::Data::Error {error}, status)
+ }
}
+ }
}
M src/deserialize.rs => src/deserialize.rs +110 -116
@@ 5,170 5,164 @@ use std::collections::HashMap;
use std::sync::Arc;
fn str_num<'de, D, T>(d: D) -> Result<T, D::Error> where D: Deserializer<'de>, T: From<u64> {
- struct Visitor;
- impl<'v> de::Visitor<'v> for Visitor {
- type Value = u64;
- fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
- write!(f, "a value which can be interpreted as a uint")
- }
- fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
- where
- E: de::Error
- {
- v.parse().map_err(|_| de::Error::invalid_value(de::Unexpected::Str(v), &"a string which can be parsed as a uint"))
- }
- fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E>
- where
- E: de::Error
- {
- Ok(v)
- }
+ struct Visitor;
+ impl<'v> de::Visitor<'v> for Visitor {
+ type Value = u64;
+ fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+ write!(f, "a value which can be interpreted as a uint")
}
- d.deserialize_any(Visitor).map(Into::into)
+ fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
+ where E: de::Error
+ {
+ v.parse().map_err(|_| de::Error::invalid_value(de::Unexpected::Str(v), &"a string which can be parsed as a uint"))
+ }
+ fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E>
+ where E: de::Error
+ {
+ Ok(v)
+ }
+ }
+ d.deserialize_any(Visitor).map(Into::into)
}
fn str_bool<'de, D, T>(d: D) -> Result<T, D::Error> where D: Deserializer<'de>, T: From<bool>{
- struct Visitor;
- impl<'v> de::Visitor<'v> for Visitor {
- type Value = bool;
- fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
- write!(f, "a value which can be interpreted as a uint")
- }
- fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
- where
- E: de::Error
- {
- match v.to_ascii_lowercase().as_str() {
- "true" | "1" => Ok(true),
- "false" | "0" => Ok(false),
- _ => Err(de::Error::invalid_value(de::Unexpected::Str(v), &"a string which can be parsed as a bool"))
- }
- }
- fn visit_bool<E>(self, v: bool) -> Result<Self::Value, E>
- where
- E: de::Error
- {
- Ok(v)
- }
+ struct Visitor;
+ impl<'v> de::Visitor<'v> for Visitor {
+ type Value = bool;
+ fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+ write!(f, "a value which can be interpreted as a uint")
+ }
+ fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
+ where E: de::Error
+ {
+ match v.to_ascii_lowercase().as_str() {
+ "true" | "1" => Ok(true),
+ "false" | "0" => Ok(false),
+ _ => Err(de::Error::invalid_value(de::Unexpected::Str(v), &"a string which can be parsed as a bool"))
+ }
+ }
+ fn visit_bool<E>(self, v: bool) -> Result<Self::Value, E>
+ where E: de::Error
+ {
+ Ok(v)
}
- d.deserialize_any(Visitor).map(Into::into)
+ }
+ d.deserialize_any(Visitor).map(Into::into)
}
#[derive(Deserialize, Debug)]
pub struct TimeStamp {
- #[serde(alias = "unixtime")]
- #[serde(alias = "uts")]
- #[serde(deserialize_with = "str_num")]
- pub unix_timestamp: u64,
- #[serde(rename = "#text")]
- pub text: Value
+ #[serde(alias = "unixtime")]
+ #[serde(alias = "uts")]
+ #[serde(deserialize_with = "str_num")]
+ pub unix_timestamp: u64,
+ #[serde(rename = "#text")]
+ pub text: Value
}
#[derive(Deserialize, Debug)]
pub struct Artist {
- #[serde(rename = "mbid")]
- pub uuid: Arc<str>,
- #[serde(alias = "#text")]
- pub name: Arc<str>,
-
- #[serde(default)]
- #[serde(rename = "image")]
- pub images: Vec<Image>,
- #[serde(default)]
- pub url: Option<Arc<str>>
+ #[serde(rename = "mbid")]
+ pub uuid: Arc<str>,
+ #[serde(alias = "#text")]
+ pub name: Arc<str>,
+
+ #[serde(default)]
+ #[serde(rename = "image")]
+ pub images: Vec<Image>,
+ #[serde(default)]
+ pub url: Option<Arc<str>>
}
#[derive(Deserialize, Debug, Ord, PartialOrd, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
#[repr(u8)]
pub enum ImageSize {
- Small,
- Medium,
- Large,
- ExtraLarge
+ Small,
+ Medium,
+ Large,
+ ExtraLarge
}
#[derive(Deserialize, Debug)]
pub struct Image {
- pub size: ImageSize,
- #[serde(rename = "#text")]
- pub url: Arc<str>,
+ pub size: ImageSize,
+ #[serde(rename = "#text")]
+ pub url: Arc<str>,
}
#[derive(Deserialize, Debug)]
pub struct Album {
- #[serde(rename = "mbid")]
- pub uuid: Arc<str>,
- #[serde(rename = "#text")]
- pub name: Arc<str>,
+ #[serde(rename = "mbid")]
+ pub uuid: Arc<str>,
+ #[serde(rename = "#text")]
+ pub name: Arc<str>,
}
#[derive(Default, Deserialize, Debug)]
pub struct TrackAttr {
- #[serde(default)]
- #[serde(deserialize_with = "str_bool")]
- pub nowplaying: bool,
- #[serde(flatten)]
- pub rest: HashMap<Arc<str>, Value>,
+ #[serde(default)]
+ #[serde(deserialize_with = "str_bool")]
+ pub nowplaying: bool,
+ #[serde(flatten)]
+ pub rest: HashMap<Arc<str>, Value>,
}
#[derive(Deserialize, Debug)]
pub struct Track {
- pub artist: Artist,
- #[serde(deserialize_with = "str_bool")]
- pub streamable: bool,
- #[serde(rename = "image")]
- pub images: Vec<Image>,
- #[serde(rename = "mbid")]
- pub uuid: Arc<str>,
- pub album: Album,
- pub name: Arc<str>,
- #[serde(rename = "@attr")]
- #[serde(default)]
- pub attr: TrackAttr,
- pub url: Arc<str>,
-
- #[serde(default)]
- #[serde(deserialize_with = "str_bool")]
- pub loved: Option<bool>,
- #[serde(default)]
- pub date: Option<TimeStamp>
+ pub artist: Artist,
+ #[serde(deserialize_with = "str_bool")]
+ pub streamable: bool,
+ #[serde(rename = "image")]
+ pub images: Vec<Image>,
+ #[serde(rename = "mbid")]
+ pub uuid: Arc<str>,
+ pub album: Album,
+ pub name: Arc<str>,
+ #[serde(rename = "@attr")]
+ #[serde(default)]
+ pub attr: TrackAttr,
+ pub url: Arc<str>,
+
+ #[serde(default)]
+ #[serde(deserialize_with = "str_bool")]
+ pub loved: Option<bool>,
+ #[serde(default)]
+ pub date: Option<TimeStamp>
}
#[derive(Deserialize, Debug)]
pub struct RecentTracks {
- pub track: Vec<Track>
+ pub track: Vec<Track>
}
#[derive(Deserialize, Debug)]
pub struct GetRecentTracks {
- pub recenttracks: RecentTracks
+ pub recenttracks: RecentTracks
}
#[derive(Deserialize, Debug)]
pub struct User {
- pub name: Arc<str>,
- #[serde(deserialize_with = "str_bool")]
- pub subscriber: bool,
- pub realname: Arc<str>,
- #[serde(deserialize_with = "str_num")]
- pub playcount: u64,
- #[serde(deserialize_with = "str_num")]
- pub artist_count: u64,
- #[serde(deserialize_with = "str_num")]
- pub playlists: u64,
- #[serde(deserialize_with = "str_num")]
- pub track_count: u64,
- #[serde(deserialize_with = "str_num")]
- pub album_count: u64,
-
- #[serde(rename = "image")]
- pub images: Vec<Image>,
-
- pub registered: TimeStamp,
- pub url: Arc<str>
+ pub name: Arc<str>,
+ pub realname: Arc<str>,
+ #[serde(deserialize_with = "str_num")]
+ pub playcount: u64,
+ #[serde(deserialize_with = "str_num")]
+ pub artist_count: u64,
+ #[serde(deserialize_with = "str_num")]
+ pub playlists: u64,
+ #[serde(deserialize_with = "str_num")]
+ pub track_count: u64,
+ #[serde(deserialize_with = "str_num")]
+ pub album_count: u64,
+
+ #[serde(rename = "image")]
+ pub images: Vec<Image>,
+
+ pub registered: TimeStamp,
+ pub url: Arc<str>
}
#[derive(Deserialize, Debug)]
pub struct GetUserInfo {
- pub user: User
+ pub user: User
}
M src/main.rs => src/main.rs +43 -43
@@ 12,54 12,54 @@ use warp::Filter;
#[derive(serde::Deserialize, Debug)]
#[serde(rename = "kebab-case")]
struct UserQuery {
- #[serde(default)]
- theme: Option<Arc<str>>,
- #[serde(flatten)]
- #[serde(default)]
- font: Option<FontQuery>,
- #[serde(flatten)]
- rest: BTreeMap<String, String>
+ #[serde(default)]
+ theme: Option<Arc<str>>,
+ #[serde(flatten)]
+ #[serde(default)]
+ font: Option<FontQuery>,
+ #[serde(flatten)]
+ rest: BTreeMap<String, String>
}
#[tokio::main]
async fn main() {
- env_logger::Builder::new()
- .filter_level(LevelFilter::Warn)
- .parse_filters(&var("LFME_LOG_LEVEL").unwrap_or_default())
- .target(
- var("LFME_LOG_FILE").ok()
- .map(
- |f| env_logger::Target::Pipe(
- Box::new(File::options()
- .append(true)
- .open(f)
- .expect("couldn't open LFME_LOG_FILE")))
- )
- .unwrap_or(env_logger::Target::Stderr)
- ).init();
+ env_logger::Builder::new()
+ .filter_level(LevelFilter::Warn)
+ .parse_filters(&var("LFME_LOG_LEVEL").unwrap_or_default())
+ .target(
+ var("LFME_LOG_FILE").ok()
+ .map(
+ |f| env_logger::Target::Pipe(
+ Box::new(File::options()
+ .append(true)
+ .open(f)
+ .expect("couldn't open LFME_LOG_FILE")))
+ )
+ .unwrap_or(env_logger::Target::Stderr)
+ ).init();
- std::sync::LazyLock::force(&STATE);
+ std::sync::LazyLock::force(&STATE);
- let user = warp::path!("user" / String)
- .and(warp::query::<UserQuery>())
- .then(|s, q: UserQuery| async move {
- log::debug!(target: "lfm::server::user", "Handling request for user `{s}` with {q:?}");
- let (ctx, dur) = STATE.get_userinfo(&s).await;
- let ResponseCtx(mut data, status) = ResponseCtx::create(ctx, q.font, q.rest).await;
-
- let theme = q.theme.filter(|a| STATE.handlebars().has_template(&a)).unwrap_or_else(|| STATE.default_theme());
- log::debug!(target: "lfm::server::user", "Using theme {theme}");
- warp::reply::with_header(
- warp::reply::with_header(
- warp::reply::with_status(
- warp::reply::html(
- STATE.handlebars().render(&theme, &data).unwrap()
- ), status
- ), "Refresh", dur.as_secs()
- ), "X-Selected-Theme", theme.as_ref()
- )
- });
+ let user = warp::path!("user" / String)
+ .and(warp::query::<UserQuery>())
+ .then(|s, q: UserQuery| async move {
+ log::debug!(target: "lfm::server::user", "Handling request for user `{s}` with {q:?}");
+ let (ctx, dur) = STATE.get_userinfo(&s).await;
+ let ResponseCtx(data, status) = ResponseCtx::create(ctx, q.font, q.rest).await;
- warp::serve(user)
- .bind(([127,0,0,1], STATE.port())).await;
+ let theme = q.theme.filter(|a| STATE.handlebars().has_template(&a)).unwrap_or_else(|| STATE.default_theme());
+ log::debug!(target: "lfm::server::user", "Using theme {theme}");
+ warp::reply::with_header(
+ warp::reply::with_header(
+ warp::reply::with_status(
+ warp::reply::html(
+ STATE.handlebars().render(&theme, &data).unwrap()
+ ), status
+ ), "Refresh", dur.as_secs()
+ ), "X-Selected-Theme", theme.as_ref()
+ )
+ });
+
+ warp::serve(user)
+ .bind(([127,0,0,1], STATE.port())).await;
}