~aleteoryx/lfm_embed

d0fc16dde4cf1c58769dfff058509677f601eb8e — alyx 1 year, 1 month ago 40372f0
config done
8 files changed, 205 insertions(+), 6 deletions(-)

M .gitignore
M Cargo.toml
A src/README.md
M src/cache.rs
A src/config.rs
M src/lib.rs
M src/main.rs
A themes/test.css
M .gitignore => .gitignore +1 -0
@@ 1,3 1,4 @@
/.env
/Cargo.lock
/target
*~

M Cargo.toml => Cargo.toml +6 -0
@@ 6,5 6,11 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
dotenv = "0.15.0"
duration-str = "0.5.1"
env_logger = "0.10.0"
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"] }

A src/README.md => src/README.md +69 -0
@@ 0,0 1,69 @@
# `lfm_embed`
A simple webserver for rendering a last.fm embed.

More specifically, this displays a simple webpage with info about your current or most recent last.fm scrobble.
Its intended use is to be put on a personal site or whatever.

## Usage
`lfm_embed` is, as it stands, designed to use a reverse proxy.
A future release may include HTTPS support, but currently, it will only listen on `localhost`, prohibiting it from public access.
Once configured, a request of the form `http://localhost:9999/<last.fm username>` will render out an embed with the default theme.

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)
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: "")
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")
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
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")
The amount of time to cache non-whitelisted user info for.
See `LFME_WHITELIST_REFRESH` for more info.

### `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.
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)
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`.

## Example Configuration
```dotenv
LFME_API_KEY=0123456789abcdef0123456789abcdef
LFME_LOG_LEVEL=error
LFME_PORT=3000

LFME_WHITELIST_REFRESH=30s
LFME_WHITELIST_MODE=exclusive
LFME_WHITELIST=a_precious_basket_case, realRiversCuomo, Pixiesfan12345
```

M src/cache.rs => src/cache.rs +2 -2
@@ 1,5 1,5 @@
use std::{future::Future, time::*, collections::HashMap, hash::Hash};

#[derive(Debug)]
pub struct AsyncCache<K, V, F> {
    func: F,
    cache: HashMap<K, (Instant, V)>,


@@ 12,7 12,7 @@ where
    K: Hash + PartialEq + Eq + Clone,
    Fut: Future<Output = Result<V, &'static str>>
{
    pub fn new(interval: Duration, mut func: F) -> Self {
    pub fn new(interval: Duration, func: F) -> Self {
        Self{
            cache: HashMap::new(),
            interval, func

A src/config.rs => src/config.rs +96 -0
@@ 0,0 1,96 @@
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};

use reqwest::Client;
use dotenv::var;
use tokio::sync::RwLock;

pub static STATE: LazyLock<Arc<State>> = LazyLock::new(|| {
    State::new()
});

fn getter(username: &String) -> Pin<Box<dyn Future<Output = Result<(User, Track), &'static str>>>> {
    Box::pin(async{Err("nope")})
}

type Cache = Arc<RwLock<AsyncCache<String, (User, Track), fn(&String) -> Pin<Box<dyn Future<Output = Result<(User, Track), &'static str>>>>>>>;
#[derive(Debug)]
enum Whitelist {
    Exclusive{cache: Cache, whitelist: HashSet<String>},
    Open{default_cache: Cache, whitelist_cache: Cache, whitelist: HashSet<String>}
}
#[derive(Debug)]
pub struct State {
    api_key: Arc<str>,
    whitelist: Whitelist,
    port: u16,
    custom_themes: HashMap<String, Arc<str>>,
    send_refresh_header: bool
}

impl State {
    fn new() -> Arc<Self> {
        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),
            
            custom_themes: {
                if let Ok(themes_dir) = var("LFME_THEMES_DIR") {
                    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()
                }
                else { HashMap::new() }
            },
            
            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 _)))
                };
                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()                           
                    )
                };
                
                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()}
                    },
                    "exclusive" => {
                        Whitelist::Exclusive{cache: whitelist_cache(), whitelist: load_whitelist().expect("LFME_WHITELIST not set, unable to serve anyone")}
                    },
                    m => {
                        panic!("Bad whitelist mode: `{m}`");
                    }
                }
            }
        })
    }
    
    pub fn api_key(&self) -> Arc<str> { self.api_key.clone() }
    pub fn port(&self) -> u16 { self.port }
}

M src/lib.rs => src/lib.rs +6 -3
@@ 1,4 1,7 @@
#![feature(entry_insert)]
#![feature(lazy_cell)]

mod deserialize;
mod cache;
pub mod deserialize;
pub mod cache;
pub mod config;

pub use config::STATE;

M src/main.rs => src/main.rs +25 -1
@@ 1,3 1,27 @@
#![feature(lazy_cell)]

use dotenv::var;
use log::LevelFilter;
use std::fs::File;
use lfm_embed::STATE;

fn main() {
    println!("Hello, world!");
    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);

    
}

A themes/test.css => themes/test.css +0 -0