~aleteoryx/lfm_embed

4bec6679a8af2a2b5cb53610a80dece3b6d30bb4 — alyx 1 year, 4 months ago 60f8bc8
lots of stuff. theming basically done, added some logging, frontend basically done.
8 files changed, 278 insertions(+), 50 deletions(-)

M Cargo.toml
M README.md
M src/cache.rs
M src/config.rs
A src/ctx.rs
M src/deserialize.rs
M src/lib.rs
M src/main.rs
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;
}