~aleteoryx/lfm_embed

d2c27e17897f80929d2ba5fed1055eade27a6b08 — alyx 8 months ago 7e4da5f
Cleanup
5 files changed, 459 insertions(+), 523 deletions(-)

M src/cache.rs
M src/config.rs
M src/ctx.rs
M src/deserialize.rs
M src/main.rs
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;
}