~aleteoryx/lfm_embed

22c2e4e2db9ad9d892ed5fb63d92254677f6dafd — alyx 8 months ago dc6875c
Reliable user info; Font refactor

Hit a few more endpoints to fix missing images, fallback to default album art just in case.

Refactor the font cache into its own file.
5 files changed, 119 insertions(+), 67 deletions(-)

M src/cache.rs
M src/config.rs
M src/ctx.rs
M src/deserialize.rs
M src/font.rs
M src/cache.rs => src/cache.rs +14 -1
@@ 1,5 1,14 @@
use std::{future::Future, time::*, collections::HashMap, hash::Hash};
use std::future::Future;
use std::time::*;
use std::collections::HashMap;
use std::hash::Hash;
use std::sync::Arc;
use std::pin::Pin;

use tokio::sync::RwLock;

use reqwest::StatusCode;

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


@@ 66,3 75,7 @@ where
    self.get(key).await.cloned()
  }
}

pub type CacheFuture<Output> = Pin<Box<(dyn Future<Output = Result<Output, (StatusCode, &'static str)>> + Send + Sync)>>;
pub type CacheGetter<Output> = fn(&String) -> CacheFuture<Output>;
pub type Cache<Output>       = Arc<RwLock<AsyncCache<String, Output, CacheGetter<Output>>>>;

M src/config.rs => src/config.rs +16 -37
@@ 6,7 6,8 @@ use std::pin::Pin;
use std::time::*;

use super::cache::AsyncCache;
use super::deserialize::{GetRecentTracks, GetUserInfo, Track, User};
use super::deserialize::{GetRecentTracks, GetUserInfo, GetTrackInfo, Track, TrackStub, User};
use super::font::{font_cache, FontCache};

use reqwest::{Client, StatusCode};
use dotenv::var;


@@ 18,13 19,9 @@ type CacheFuture<Output> = Pin<Box<(dyn Future<Output = Result<Output, (StatusCo
type CacheGetter<Output> = fn(&String) -> CacheFuture<Output>;
type Cache<Output>       = Arc<RwLock<AsyncCache<String, Output, CacheGetter<Output>>>>;

type FontFuture = CacheFuture<Arc<str>>;
type FontGetter = CacheGetter<Arc<str>>;
type FontCache  = Cache<Arc<str>>;

type UserFuture = CacheFuture<Arc<(User, Track)>>;
type UserGetter = CacheGetter<Arc<(User, Track)>>;
type UserCache  = Cache<Arc<(User, Track)>>;
type UserFuture = CacheFuture<Arc<(User, Track, TrackStub)>>;
type UserGetter = CacheGetter<Arc<(User, Track, TrackStub)>>;
type UserCache  = Cache<Arc<(User, Track, TrackStub)>>;

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



@@ 49,40 46,22 @@ fn user_getter(username: &String) -> UserFuture {
    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
    let trackstub = 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().next().ok_or((StatusCode::UNPROCESSABLE_ENTITY, "You need to listen to some songs first!"))?;

    Ok(Arc::new((userinfo, tracksinfo)))
  })
}

fn font_getter(fontname: &String) -> FontFuture {
  let fontname = urlencoding::encode(fontname.as_ref()).to_string();
  Box::pin(async move {
    let Some(google_api_key) = STATE.google_api_key.clone()
    else {
      unreachable!();
    };

    let fontreq = STATE.http.get(format!("https://www.googleapis.com/webfonts/v1/webfonts?key={}&family={fontname}", google_api_key))
    let trackreq = STATE.http.get(format!("https://ws.audioscrobbler.com/2.0/?method=track.getInfo&format=json&username={username}&api_key={}&track={}&artist={}", STATE.lastfm_api_key, trackstub.name, trackstub.artist.name))
      .send().await
      .map_err(|e| {log::error!("Failed to get info for font `{fontname}`: {e}"); (StatusCode::SERVICE_UNAVAILABLE, "Couldn't connect to Google Fonts!")})?;
    if fontreq.status() == StatusCode::NOT_FOUND { return Err((StatusCode::NOT_FOUND, "Font does not exist!")); }
    if fontreq.status() == StatusCode::FORBIDDEN {
      log::error!("Invalid Google API key in config!");
      return Err((StatusCode::SERVICE_UNAVAILABLE, "This instance is not configured to support Google Fonts properly, please use a different font."));
    }
      .map_err(|e| {log::error!("Failed to get tracks for user `{username}`: {e}"); (StatusCode::SERVICE_UNAVAILABLE, "Couldn't connect to last.fm!")})?;
    if trackreq.status() == StatusCode::NOT_FOUND { return Err((StatusCode::NOT_FOUND, "Track does not exist!")); }

    let cssreq = STATE.http.get(format!("https://fonts.googleapis.com/css2?family={fontname}"))
      .send().await
      .map_err(|e| {log::error!("Failed to get CSS for font `{fontname}`: {e}"); (StatusCode::SERVICE_UNAVAILABLE, "Couldn't download font CSS!")})?;
    let trackinfo = trackreq.json::<GetTrackInfo>().await
      .map_err(|e| {log::error!("Couldn't parse track.getInfo for `{}` by `{}` on behalf of {username}: {e}", trackstub.name, trackstub.artist.name); (StatusCode::INTERNAL_SERVER_ERROR, "Couldn't parse track.getInfo!")})?.track;

    Ok(cssreq.text().await.unwrap().into())
    Ok(Arc::new((userinfo, trackinfo, trackstub)))
  })
}


#[derive(Debug)]
enum Whitelist {
  Exclusive{cache: UserCache, whitelist: BTreeSet<String>},


@@ 95,11 74,11 @@ pub struct State {
  default_theme: Arc<str>,
  send_refresh_header: bool,

  http: Client,
  pub(crate) http: Client,

  handlebars: Handlebars<'static>,

  google_api_key: Option<Arc<str>>,
  pub(crate) google_api_key: Option<Arc<str>>,
  google_fonts_cache: FontCache,

  whitelist: Whitelist,


@@ 146,7 125,7 @@ impl State {
      },

      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))),
      google_fonts_cache: font_cache(),

      whitelist: {
        let load_whitelist = || -> Option<BTreeSet<String>> {


@@ 178,7 157,7 @@ impl State {
    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) {
  pub async fn get_userinfo(&self, user: &String) -> (Result<Arc<(User, Track, TrackStub)>, (StatusCode, &'static str)>, Duration) {
    match &self.whitelist {
      Whitelist::Open{default_cache, whitelist_cache, whitelist} => {
        if whitelist.contains(user) {

M src/ctx.rs => src/ctx.rs +6 -6
@@ 40,7 40,7 @@ pub mod model {
    pub name: Arc<str>,

    /// A link to their current image.
    pub image_url: Arc<str>,
//    pub image_url: Arc<str>,
    /// A link to their last.fm page.
    pub url: Arc<str>
  }


@@ 106,10 106,10 @@ pub mod model {
pub struct ResponseCtx(pub model::Root, 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 {
  pub async fn create(api_result: Result<Arc<(de::User, de::Track, de::TrackStub)>, (StatusCode, &'static str)>, font_query: Option<FontQuery>, query: BTreeMap<String, String>) -> ResponseCtx {
    match api_result {
      Ok(a) => {
        let (user, track) = a.as_ref();
        let (user, track, trackstub) = a.as_ref();
        ResponseCtx((model::Data {
          user: model::User {
            name: user.name.clone(),


@@ 129,11 129,11 @@ impl ResponseCtx {
            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()),
//              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,
            image_url: track.images.iter().max_by(|a, b| a.size.cmp(&b.size)).map(|a| a.url.clone()).filter(|s| !s.is_empty()).unwrap_or_else(|| "https://lastfm.freetls.fastly.net/i/u/128s/4128a6eb29f94943c9d206c08e625904.jpg".into()),
            now_playing: trackstub.attr.nowplaying,
            url: track.url.clone(),
            loved: track.loved.unwrap_or(false)
          },

M src/deserialize.rs => src/deserialize.rs +43 -23
@@ 62,15 62,10 @@ pub struct TimeStamp {

#[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>>
}



@@ 97,43 92,68 @@ pub struct Album {
  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(rename = "image")]
  pub images: Vec<Image>,

}


#[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 mbid: Arc<str>,
  pub album: Album,
  pub name: Arc<str>,
  #[serde(rename = "@attr")]
  #[serde(default)]
  pub attr: TrackAttr,
  pub url: Arc<str>,
  #[serde(deserialize_with = "str_num")]
  pub duration: u64,
  #[serde(deserialize_with = "str_num")]
  pub listeners: u64,
  #[serde(deserialize_with = "str_num")]
  pub playcount: u64,

  #[serde(default)]
  #[serde(rename = "userloved")]
  #[serde(deserialize_with = "str_bool")]
  pub loved: Option<bool>,
  #[serde(default)]
  pub date: Option<TimeStamp>
  #[serde(deserialize_with = "str_num")]
  pub userplaycount: u64,
}

#[derive(Deserialize, Debug)]
pub struct GetTrackInfo {
  pub track: Track
}

#[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>,
}
#[derive(Deserialize, Debug)]
pub struct ArtistStub {
  #[serde(rename = "#text")]
  pub name: Arc<str>
}
#[derive(Deserialize, Debug)]
pub struct TrackStub {
  pub name: Arc<str>,
  pub artist: ArtistStub,
  #[serde(default)]
  pub date: Option<TimeStamp>,
  #[serde(rename = "@attr")]
  #[serde(default)]
  pub attr: TrackAttr,
}
#[derive(Deserialize, Debug)]
pub struct RecentTracks {
  pub track: Vec<Track>
  pub track: Vec<TrackStub>
}
#[derive(Deserialize, Debug)]
pub struct GetRecentTracks {

M src/font.rs => src/font.rs +40 -0
@@ 1,4 1,11 @@
use std::sync::Arc;
use std::time::Duration;

use tokio::sync::RwLock;
use reqwest::StatusCode;

use super::cache::{CacheFuture, CacheGetter, Cache, AsyncCache};
use crate::STATE;

#[derive(serde::Deserialize, Debug, Default)]
#[serde(default)]


@@ 9,3 16,36 @@ pub struct FontQuery {
  pub google_font: Option<Arc<str>>,
//  pub small_font: Option<()>
}

pub type FontFuture = CacheFuture<Arc<str>>;
pub type FontGetter = CacheGetter<Arc<str>>;
pub type FontCache  = Cache<Arc<str>>;

fn font_getter(fontname: &String) -> FontFuture {
  let fontname = urlencoding::encode(fontname.as_ref()).to_string();
  Box::pin(async move {
    let Some(google_api_key) = STATE.google_api_key.clone()
    else {
      unreachable!();
    };

    let fontreq = STATE.http.get(format!("https://www.googleapis.com/webfonts/v1/webfonts?key={}&family={fontname}", google_api_key))
      .send().await
      .map_err(|e| {log::error!("Failed to get info for font `{fontname}`: {e}"); (StatusCode::SERVICE_UNAVAILABLE, "Couldn't connect to Google Fonts!")})?;
    if fontreq.status() == StatusCode::NOT_FOUND { return Err((StatusCode::NOT_FOUND, "Font does not exist!")); }
    if fontreq.status() == StatusCode::FORBIDDEN {
      log::error!("Invalid Google API key in config!");
      return Err((StatusCode::SERVICE_UNAVAILABLE, "This instance is not configured to support Google Fonts properly, please use a different font."));
    }

    let cssreq = STATE.http.get(format!("https://fonts.googleapis.com/css2?family={fontname}"))
      .send().await
      .map_err(|e| {log::error!("Failed to get CSS for font `{fontname}`: {e}"); (StatusCode::SERVICE_UNAVAILABLE, "Couldn't download font CSS!")})?;

    Ok(cssreq.text().await.unwrap().into())
  })
}

pub fn font_cache() -> FontCache {
  Arc::new(RwLock::new(AsyncCache::new(Duration::from_secs(86400), font_getter as FontGetter)))
}