~aleteoryx/lfm_embed

9edafd94b00a73662d51824dbcba0a018e2140cf — alyx 1 year, 1 month ago 0216a2a
Create plain theme, do some escaping, fix some busted envvars, document themes.
8 files changed, 227 insertions(+), 29 deletions(-)

M Cargo.toml
M README.md
A screenshots/plain-light.png
M src/config.rs
M src/ctx.rs
M src/main.rs
A src/themes/plain.hbs
D themes/test.css
M Cargo.toml => Cargo.toml +2 -0
@@ 14,9 14,11 @@ dotenv = "0.15.0"
duration-str = "0.5.1"
env_logger = "0.10.0"
handlebars = { version = "4.3.7", features = ["dir_source"] }
htmlize = "1.0.3"
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"] }
urlencoding = "2.1.3"
warp = "0.3.5"

M README.md => README.md +152 -9
@@ 2,24 2,45 @@
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.
Its intended use is to be put in an iframe on a personal site or whatever.
While it is self-hostable, there is an official public instance at `https://lfm.aleteoryx.me`.

# Usage

`lfm_embed` serves one thing:
Requests to `/user/<last.fm username>` will generate a page displaying your current or last last.fm scrobble.
The look and feel of this is down to the configured theme for your request.

As the user, you may select a theme supported by the server by passing `?theme=` query string.
Check with your host for a list of supported themes.

Individual themes may also support parameters, via additional query parameters.

## Builtin Themes

### `plain`

A minimal theme, displaying just the username, album art, and track info.
It is the default fallback for an unset or unknown theme.

![A screenshot of the plain theme, displaying album art, and the text "@vvinrg is scrobbling Sweet Tooth from Sleepyhead by Cavetown".](screenshots/plain-light.png)

#### Parameters

- `dark`: toggle the theme's dark mode.

# Self-Hosting
`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.

***

## Core Config

### `LMFE_API_KEY` (Required)


@@ 29,7 50,7 @@ Your last.fm API key. You'll need to create one [here](https://www.last.fm/api/a
The port to serve on locally.

### `LFME_NO_REFRESH` (Default: `0`)
If set to `1`, disable outputting of the HTML `meta http-eqiv="refresh"` tag, used for live status updates.
If set to `1`, disable outputting of the HTTP `Refresh: <cache refresh>` header, used to provide live status updates.

## Logging



@@ 74,6 95,8 @@ If set, must be a valid path to a directory containing [Handlebars](https://hand
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.

See [Theming](#theming) for details on writing custom themes.

Theme names are the same as their path, minus the extension. Given an extension of .hbs, a directory like:
```
themes/


@@ 94,9 117,11 @@ alices-themes/mysuperawesometheme

By default, these are loaded and compiled once, at startup.

### `LFME_THEME_EXT` (Default: `hbs`)
### `LFME_THEME_EXT` (Default: `.hbs`)
The file extension for themes in `LFME_THEME_DIR`.

Note: This behaves more like a suffix than a file extension. You must include the leading dot for a file extension.

### `LFME_THEME_DEV` (Default: `0`)
If set to `1`, existing themes will be reloaded on edit.



@@ 116,7 141,125 @@ LFME_WHITELIST_REFRESH=30s
LFME_WHITELIST_MODE=exclusive
LFME_WHITELIST=a_precious_basket_case, realRiversCuomo, Pixiesfan12345
```
***

# Theming
# Theming {#theming}

Custom themes are, as stated above, [Handlebars](https://handlebarsjs.com/guide/#language-features) files.
They are expected to produce a complete HTML doc, which should contain info about a user's scrobbles.

See `src/themes` for examples of implementation.

## Writing a Theme

Themes should have, roughly, the structure below:

```html
<!DOCTYPE html>
<html lang="en"> <!-- Or really whatever you want, but I speak English. -->
    <head>
        <meta charset="UTF-8">
        <style> /* styles */ </style>
    </head>
    <body>
        {{#if error}}<p>{{error}}</p>{{else}}
            <!-- theme contents here -->
        {{/if}}
    </body>
</html>
```

You will be passed one of 2 context objects, depending on if there was an error processing the request.
If there was an error, the object will just be `{ error: String }`, otherwise it will be the following:

```js
{
    // The user the request was made on the behalf of.
    user: {
        // Their username.
        name: String,
        // Their "Display Name".
        realname: String,
        // Link to user's profile picture.
        image_url: String,
        // Link to user's profile.
        url: String,
        
        // Total scrobbles.
        scrobble_count: Number,
        // Number of artists in library.
        artist_count: Number,
        // Number of tracks in library.
        track_count: Number,
        // Number of albums in library.
        album_count: Number,
        
        // True if user subscribes to last.fm pro.
        pro_subscriber: Boolean
    },
    
    // The user's most current, or most scrobble.
    scrobble: {
        // The name of the track.
        name: String,
        // The name of its album.
        album: String,
        // The artist who made it.
        artist: {
            // Their name.
            name: String,
            // Link to their profile image.
            image_url: String,
            // Link to the artist's last.fm page.
            url: String
        },
        // A link to the track image.
        image_url: String,
        // A link to the track's last.fm page.
        url: String,

        // True if the user has loved the track.
        loved: Boolean
        // True if the user is currently scrobbling it, false if it's just
        // the most recently played track.
        now_playing: Boolean,
    },
    
    // A set of extraneous query parameters.
    //
    // This should be considered UNTRUSTED, as the requester has full
    // control over it. Use the provided `html_escape`,
    // `html_attr_escape`, and `uri_encode` helpers when inlining
    // any contained text.
    query: Object
}
```

In addition, the following [helpers](https://handlebarsjs.com/guide/expressions.html#helpers) are provided, on top of the handlebars [builtins](https://handlebarsjs.com/guide/builtin-helpers.html):

- `(eq Object Object) => Boolean`: Check equality between its args.

- `(ne Object Object) => Boolean`: Check inequality between its args.

- `(gt Number|String Number|String) => Boolean`: Check if the first arg is greater than the second.

- `(gte Number|String Number|String) => Boolean`: Check if the first arg is greater than or equal to the second.

- `(lt Number|String Number|String) => Boolean`: Check if the first arg is less than the second.

- `(lte Number|String Number|String) => Boolean`: Check if the first arg is less than or equal to the second.

- `(and Boolean Boolean) => Boolean`: Boolean AND gate.

- `(or Boolean Boolean) => Boolean`: Boolean OR gate.

- `(not Boolean) => Boolean`: Boolean NOT gate.

- `(html_escape String) => String`: Escape HTML special characters from the input string, replacing them with HTML entities in the output. For use in standard markdown.

- `(html_attr_escape String) => String`: Escape HTML special characters, as well as quotation marks, in the input, replacing them with HTML entities and escaped quotes in the output. For use in HTML tag attributes.

- `(uri_encode String) => String`: URI-encode input text, making the output suitable to be included as part of a link or other URL.

# Contributing

[E-Mail me](mailto:alyx@aleteoryx.me) if you'd like to help out, submit a custom theme, or request a feature.

A screenshots/plain-light.png => screenshots/plain-light.png +0 -0
M src/config.rs => src/config.rs +15 -6
@@ 11,17 11,17 @@ use super::deserialize::{GetRecentTracks, GetUserInfo, Track, User};
use reqwest::{Client, StatusCode};
use dotenv::var;
use tokio::sync::RwLock;
use handlebars::Handlebars;
use handlebars::{Handlebars, handlebars_helper};
use duration_str as ds;

static INTERNAL_THEMES: &[(&'static str, &'static str)] = &[("plain", "")];
static INTERNAL_THEMES: &[(&'static str, &'static str)] = &[("plain", include_str!("themes/plain.hbs"))];

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

fn getter(username: &String) -> Pin<Box<(dyn Future<Output = Result<Arc<(User, Track)>, (StatusCode, &'static str)>> + Send + Sync)>> {
    let username = username.clone();
    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.api_key))
            .send().await


@@ 84,6 84,15 @@ impl State {
            
            handlebars: {
                let mut hb = Handlebars::new();
                
                handlebars_helper!(html_escape: |s: String| htmlize::escape_text(s));
                handlebars_helper!(html_attr_escape: |s: String| htmlize::escape_attribute(s));
                handlebars_helper!(url_encode: |s: String| urlencoding::encode(&s));

                hb.register_helper("html-escape", Box::new(html_escape));
                hb.register_helper("html-attr-escape", Box::new(html_attr_escape));
                hb.register_helper("url-encode", Box::new(html_attr_escape));
                
                for (key, fulltext) in INTERNAL_THEMES {
                    log::info!(target: "lfm::config::theme", "Registering internal theme `{key}`");
                    hb.register_template_string(key, fulltext).unwrap();


@@ 92,7 101,7 @@ impl State {
                
                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.register_templates_directory(&var("LFME_THEME_EXT").unwrap_or_else(|_| ".hbs".into()), themes_dir).unwrap();
                }

                hb


@@ 118,8 127,8 @@ impl State {
                    }
                }
            },
            default_refresh: default_refresh + Duration::from_secs(5),
            whitelist_refresh: whitelist_refresh + Duration::from_secs(5)
            default_refresh: default_refresh + Duration::from_secs(1),
            whitelist_refresh: whitelist_refresh + Duration::from_secs(1)
        })
    }
    

M src/ctx.rs => src/ctx.rs +8 -4
@@ 1,9 1,11 @@
use reqwest::StatusCode;
use super::deserialize as de;
use std::sync::Arc;
use std::collections::BTreeMap;

pub mod model {
    use std::sync::Arc;
    use std::collections::BTreeMap;
    
    /// The theme representation of a user.
    #[derive(serde::Serialize, Debug)]


@@ 73,14 75,15 @@ pub mod model {
        /// 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 }
        Data { user: User, scrobble: Scrobble, query: BTreeMap<String, String> }
    }
}
#[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 {
impl From<(Result<Arc<(de::User, de::Track)>, (StatusCode, &'static str)>, BTreeMap<String, String>)> for ResponseCtx {
    fn from(v: (Result<Arc<(de::User, de::Track)>, (StatusCode, &'static str)>, BTreeMap<String, String>)) -> ResponseCtx {
        let (v, q) = v;
        match v {
            Ok(a) => {
                let (user, track) = a.as_ref();


@@ 111,7 114,8 @@ impl From<Result<Arc<(de::User, de::Track)>, (StatusCode, &'static str)>> for Re
                        now_playing: track.attr.nowplaying,
                        url: track.url.clone(),
                        loved: track.loved.unwrap_or(false)
                    }
                    },
                    query: q
                }, StatusCode::OK)
            },
            Err((status, error)) => {

M src/main.rs => src/main.rs +16 -10
@@ 2,6 2,7 @@

use dotenv::var;
use log::LevelFilter;
use std::collections::BTreeMap;
use std::fs::File;
use std::sync::Arc;
use lfm_embed::{STATE, ResponseCtx};


@@ 10,7 11,9 @@ use warp::Filter;
#[derive(serde::Deserialize, Debug)]
struct UserQuery {
    #[serde(default)]
    theme: Option<Arc<str>>
    theme: Option<Arc<str>>,
    #[serde(flatten)]
    rest: BTreeMap<String, String>
}

#[tokio::main]


@@ 35,17 38,20 @@ async fn main() {
    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:?}");
            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) = ctx.into();
                        
            let theme = q.theme.unwrap_or_else(|| STATE.default_theme());
            let ResponseCtx(mut data, status) = (ctx, q.rest).into();
            
            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_status(
                    warp::reply::html(
                        STATE.handlebars().render(&theme, &data).unwrap()
                    ), status
                ), "Refresh", dur.as_secs()
                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()
            )
        });


A src/themes/plain.hbs => src/themes/plain.hbs +34 -0
@@ 0,0 1,34 @@
<!doctype html>
<html lang="en" style="font-size: 0.5cm; margin: 0.5rem; overflow: hidden;">
  <head>
    <meta charset="UTF-8"/>
    <title>{{#if error}}Error!{{else}}@{{user.name}}'s Last.fm Stats{{/if}}</title>
    <style>
      {{#if (eq query.dark null)}}
        :root { --b: black; color: black; backgrond-color: white; }
      {{else}}
        :root { --b: white; color: white; background-color: black; }
        a:visited { color: pink }
        a { color: cyan; }
      {{/if}}
      p { margin: 0px; padding: 0px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
    </style>
  </head>
  <body>
    {{#if error}}
      <p style="white-space: unset;">{{error}}</p>
    {{else}}
      <a style="float: left;" target="_blank" href="{{scrobble.url}}"><img src="{{scrobble.image_url}}" style="height: 4.0rem; border: solid var(--b) 0.2rem; margin: 0.1rem;" /></a>
      <p><a target="_blank" href="{{user.url}}">@{{user.name}}</a>{{#if scrobble.now_playing}}
        is scrobbling{{else}}'s last scrobble was
      {{/if}}
      </p><p>
        <i><b><a target="_blank" href="{{scrobble.url}}">{{scrobble.name}}</a></b></i>
      </p><p>
        {{#if scrobble.album}}from <i><b>{{scrobble.album}}</b></i>{{/if}}
      </p><p>
        by <b><a target="_blank" href="{{scrobble.artist.url}}">{{scrobble.artist.name}}</a></b>.
      </p>
    {{/if}}
  </body>
</html>
\ No newline at end of file

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