diff options
-rw-r--r-- | Cargo.toml | 2 | ||||
-rw-r--r-- | README.md | 161 | ||||
-rw-r--r-- | screenshots/plain-light.png | bin | 0 -> 25504 bytes | |||
-rw-r--r-- | src/config.rs | 21 | ||||
-rw-r--r-- | src/ctx.rs | 12 | ||||
-rw-r--r-- | src/main.rs | 26 | ||||
-rw-r--r-- | src/themes/plain.hbs | 34 | ||||
-rw-r--r-- | themes/test.css | 0 |
8 files changed, 227 insertions, 29 deletions
@@ -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" @@ -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. diff --git a/screenshots/plain-light.png b/screenshots/plain-light.png Binary files differnew file mode 100644 index 0000000..46b93bd --- /dev/null +++ b/screenshots/plain-light.png diff --git a/src/config.rs b/src/config.rs index aad298f..ab92108 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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) }) } @@ -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)) => { diff --git a/src/main.rs b/src/main.rs index 729482f..b787049 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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() ) }); diff --git a/src/themes/plain.hbs b/src/themes/plain.hbs new file mode 100644 index 0000000..cbe8948 --- /dev/null +++ b/src/themes/plain.hbs @@ -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 diff --git a/themes/test.css b/themes/test.css deleted file mode 100644 index e69de29..0000000 --- a/themes/test.css +++ /dev/null |