aboutsummaryrefslogtreecommitdiffstats
path: root/src/config.rs
blob: 3c11bc8cc50f7dc3300eb370b463aba5bce347d9 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
use std::collections::BTreeSet;
use std::sync::LazyLock;
use std::sync::Arc;
use std::future::Future;
use std::pin::Pin;
use std::time::*;

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

use reqwest::{Client, StatusCode};
use dotenv::var;
use tokio::sync::RwLock;
use handlebars::{Handlebars, handlebars_helper};
use duration_str as ds;

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

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"))];

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

fn user_getter(username: &String) -> UserFuture {
  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.lastfm_api_key))
      .send().await
      .map_err(|e| {log::error!("Failed to get info for user `{username}`: {e}"); (StatusCode::SERVICE_UNAVAILABLE, "Couldn't connect to last.fm!")})?;
    if userreq.status() == StatusCode::NOT_FOUND { return Err((StatusCode::NOT_FOUND, "User does not exist!")); }

    let userinfo = userreq.json::<GetUserInfo>().await
      .map_err(|e| {log::error!("Couldn't parse user.getInfo for `{username}`: {e}"); (StatusCode::INTERNAL_SERVER_ERROR, "Couldn't parse user.getInfo!")})?.user;

    let tracksreq = STATE.http.get(format!("https://ws.audioscrobbler.com/2.0/?method=user.getRecentTracks&format=json&extended=1&limit=1&user={username}&api_key={}", STATE.lastfm_api_key))
      .send().await
      .map_err(|e| {log::error!("Failed to get tracks for user `{username}`: {e}"); (StatusCode::SERVICE_UNAVAILABLE, "Couldn't connect to last.fm!")})?;
    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 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!"))?;

    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 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 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(Arc::new((userinfo, trackinfo, trackstub)))
  })
}

#[derive(Debug)]
enum Whitelist {
  Exclusive{cache: UserCache, whitelist: BTreeSet<String>},
  Open{default_cache: UserCache, whitelist_cache: UserCache, whitelist: BTreeSet<String>}
}
#[derive(Debug)]
pub struct State {
  lastfm_api_key: Arc<str>,
  port: u16,
  default_theme: Arc<str>,
  send_refresh_header: bool,

  pub(crate) http: Client,

  handlebars: Handlebars<'static>,

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

  whitelist: Whitelist,
  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 user_cache_from_duration = |d: Duration| -> UserCache {
      Arc::new(RwLock::new(AsyncCache::new(d, user_getter as UserGetter)))
    };
    let default_refresh = duration_from_var("LFME_DEFAULT_REFRESH", 300);
    let whitelist_refresh = duration_from_var("LFME_WHITELIST_REFRESH", 60);
    let default_cache = user_cache_from_duration(default_refresh);
    let whitelist_cache = user_cache_from_duration(whitelist_refresh);
    Arc::new(State {
      lastfm_api_key: var("LFME_LASTFM_API_KEY").expect("last.fm API key must be set").into(),
      port: var("LFME_PORT").map(|p| p.parse().expect("cannot parse as a port number")).unwrap_or(9999),
      default_theme: var("LFME_THEME_DEFAULT").map(Into::into).unwrap_or_else(|_| "plain".into()),
      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(),

      handlebars: {
        let mut hb = Handlebars::new();
        handlebars_helper!(url_encode: |s: String| urlencoding::encode(&s));

        hb.register_helper("url-encode", Box::new(url_encode));

        for (key, fulltext) in INTERNAL_THEMES {
          log::info!(target: "lfm::config::theme", "Registering internal theme `{key}`");
          hb.register_template_string(key, fulltext).unwrap();
        }
        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
      },

      google_api_key: var("LFME_GOOGLE_API_KEY").map(Into::into).ok(),
      google_fonts_cache: font_cache(),

      whitelist: {
        let load_whitelist = || -> Option<BTreeSet<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, 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}`");
          }
        }
      },
      default_refresh: default_refresh + Duration::from_secs(1),
      whitelist_refresh: whitelist_refresh + Duration::from_secs(1)
    })
  }

  pub fn port(&self) -> u16 { self.port }
  pub fn send_refresh_header(&self) -> bool { self.send_refresh_header }
  pub async fn get_fontinfo(&self, font: &String) -> Result<Arc<str>, (StatusCode, &'static str)> {
    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, TrackStub)>, (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() }
}