From 0765156697d1820699bec982ce4b318bc8bce68b Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 26 Apr 2025 19:23:30 -0400 Subject: [PATCH] add: last.fm status integration --- Cargo.lock | 8 ++ crates/app/src/assets.rs | 2 + .../app/src/public/html/auth/connection.html | 25 ++++ crates/app/src/public/html/components.html | 34 ++++++ crates/app/src/public/html/profile/base.html | 2 + .../app/src/public/html/profile/settings.html | 12 +- crates/app/src/public/html/root.html | 31 +++++ .../app/src/public/images/vendor/last-fm.svg | 1 + crates/app/src/public/js/me.js | 106 +++++++++++++++++ .../routes/api/v1/auth/connections/last_fm.rs | 111 ++++++++++++++++++ .../src/routes/api/v1/auth/connections/mod.rs | 1 + crates/app/src/routes/api/v1/mod.rs | 8 ++ crates/core/Cargo.toml | 6 +- crates/core/src/config.rs | 8 ++ .../core/src/database/connections/last_fm.rs | 42 +++++++ crates/core/src/database/connections/mod.rs | 1 + crates/core/src/model/auth.rs | 2 + 17 files changed, 397 insertions(+), 3 deletions(-) create mode 100644 crates/app/src/public/images/vendor/last-fm.svg create mode 100644 crates/app/src/routes/api/v1/auth/connections/last_fm.rs create mode 100644 crates/core/src/database/connections/last_fm.rs diff --git a/Cargo.lock b/Cargo.lock index 93d7df8..07b5df4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -308,6 +308,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base32" version = "0.5.1" @@ -3247,8 +3253,10 @@ name = "tetratto-core" version = "1.0.8" dependencies = [ "async-recursion", + "base16ct", "bb8-postgres", "bitflags 2.9.0", + "md-5", "pathbufd", "redis", "reqwest", diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index b52bdbb..2ba3c38 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -96,6 +96,7 @@ pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); // ... pub const VENDOR_SPOTIFY_ICON: &str = include_str!("./public/images/vendor/spotify.svg"); +pub const VENDOR_LAST_FM_ICON: &str = include_str!("./public/images/vendor/last-fm.svg"); /// A container for all loaded icons. pub(crate) static ICONS: LazyLock>> = @@ -193,6 +194,7 @@ pub(crate) async fn replace_in_html(input: &str, config: &Config) -> String { /// Set up public directories. pub(crate) async fn write_assets(config: &Config) -> PathBufD { vendor_icon!("spotify", VENDOR_SPOTIFY_ICON, config.dirs.icons); + vendor_icon!("last_fm", VENDOR_LAST_FM_ICON, config.dirs.icons); // ... let html_path = PathBufD::current().join(&config.dirs.templates); diff --git a/crates/app/src/public/html/auth/connection.html b/crates/app/src/public/html/auth/connection.html index bf6ae24..698b58a 100644 --- a/crates/app/src/public/html/auth/connection.html +++ b/crates/app/src/public/html/auth/connection.html @@ -37,4 +37,29 @@ config.connections.spotify_client_id %} `${message}. You can now close this tab.`; }, 150); +{% elif connection_type == "LastFm" and user and user.connections.LastFm and +config.connections.last_fm_key %} + {% endif %} {% endblock %} diff --git a/crates/app/src/public/html/components.html b/crates/app/src/public/html/components.html index 125e007..83fb6df 100644 --- a/crates/app/src/public/html/components.html +++ b/crates/app/src/public/html/components.html @@ -847,4 +847,38 @@ state.data %} +{% endif %} {%- endmacro %} {% macro last_fm_playing(state, size="60px") -%} {% +if state and state.data %} +
+
+
+ Listening on + {{ icon "last_fm" }} +
+
+ + +
{% endif %} {%- endmacro %} diff --git a/crates/app/src/public/html/profile/base.html b/crates/app/src/public/html/profile/base.html index 2752715..628921f 100644 --- a/crates/app/src/public/html/profile/base.html +++ b/crates/app/src/public/html/profile/base.html @@ -109,6 +109,8 @@
{% if profile.connections.Spotify and profile.connections.Spotify[0].data.name and profile.connections.Spotify[0].show_on_profile %} {{ components::spotify_playing(state=profile.connections.Spotify[1]) }} + {% elif profile.connections.LastFm and profile.connections.LastFm[0].data.name and profile.connections.LastFm[0].show_on_profile %} + {{ components::last_fm_playing(state=profile.connections.LastFm[1]) }} {% endif %}
diff --git a/crates/app/src/public/html/profile/settings.html b/crates/app/src/public/html/profile/settings.html index fb62631..086dd49 100644 --- a/crates/app/src/public/html/profile/settings.html +++ b/crates/app/src/public/html/profile/settings.html @@ -515,13 +515,23 @@ {{ icon "spotify" }} Spotify + {% endif %} {% if config.connections.last_fm_key and not + user.connections.LastFm %} + {% endif %} {% for key, value in user.connections %}
- {% if key == "Spotify" %} {{ icon "spotify" }} {% endif %} + {% if key == "Spotify" %} {{ icon "spotify" }} {% elif key == + "LastFm" %} {{ icon "last_fm" }} {% endif %} {% if value[0].data.name %} {{ value[0].data.name }} {% else diff --git a/crates/app/src/public/html/root.html b/crates/app/src/public/html/root.html index e7c5b46..fdfd0e4 100644 --- a/crates/app/src/public/html/root.html +++ b/crates/app/src/public/html/root.html @@ -381,6 +381,37 @@ macros -%} } }, 150); + {% elif user and user.connections.LastFm and + config.connections.last_fm_key and + user.connections.LastFm[0].data.session_token %} + {% endif %} diff --git a/crates/app/src/public/images/vendor/last-fm.svg b/crates/app/src/public/images/vendor/last-fm.svg new file mode 100644 index 0000000..5d25d33 --- /dev/null +++ b/crates/app/src/public/images/vendor/last-fm.svg @@ -0,0 +1 @@ + diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index eca1e17..164002e 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -578,3 +578,109 @@ }, ); })(); + +(() => { + const self = reg_ns("last_fm"); + + self.define("api", async (_, method, data) => { + return JSON.parse( + ( + await ( + await fetch( + "/api/v1/auth/user/connections/last_fm/api_proxy", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + method, + data, + }), + }, + ) + ).json() + ).payload, + ); + }); + + self.define("create_connection", (_, client_id) => { + fetch("/api/v1/auth/user/connections/last_fm", { + method: "POST", + }) + .then((res) => res.json()) + .then(async (_) => { + const params = new URLSearchParams(); + params.append("api_key", client_id); + params.append( + "cb", + `${window.location.origin}/auth/connections_link/LastFm`, + ); + + window.open(`https://last.fm/api/auth?${params.toString()}`); + + window.location.reload(); + }); + }); + + self.define("get_session", async ({ $ }, token) => { + return await $.api("auth.getSession", { + token, + }); + }); + + self.define("get_playing", async ({ $ }, user, session_token) => { + // + return ( + await $.api("user.getRecentTracks", { + user, + sk: session_token, + limit: "1", + extended: "1", + }) + ).recenttracks.track[0]; + }); + + self.define("publish_playing", async (_, playing) => { + if (!playing || !playing["@attr"] || !playing["@attr"].nowplaying) { + return await trigger("connections::push_con_state", [ + "LastFm", + { + external_urls: {}, + data: {}, + }, + ]); + } + + if ( + window.localStorage.getItem("atto:connections.last_fm/name") === + playing.name + ) { + // item already pushed to connection, no need right now + return; + } + + window.localStorage.setItem( + "atto:connections.last_fm/name", + playing.name, + ); + + return await trigger("connections::push_con_state", [ + "LastFm", + { + external_urls: { + track: playing.url, + artist: playing.artist.url, + track_img: playing.image[2]["#text"], + }, + data: { + id: playing.mbid, + // track + track: playing.name, + artist: playing.artist.name, + album: playing.album["#text"], + }, + }, + ]); + }); +})(); diff --git a/crates/app/src/routes/api/v1/auth/connections/last_fm.rs b/crates/app/src/routes/api/v1/auth/connections/last_fm.rs new file mode 100644 index 0000000..b4502b5 --- /dev/null +++ b/crates/app/src/routes/api/v1/auth/connections/last_fm.rs @@ -0,0 +1,111 @@ +use std::collections::HashMap; +use axum::{response::IntoResponse, Extension, Json}; +use axum_extra::extract::CookieJar; +use tetratto_core::{ + database::connections::last_fm::LastFmConnection, + model::{ + auth::{ConnectionService, ExternalConnectionData}, + ApiReturn, Error, + }, +}; +use crate::{get_user_from_token, State}; +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct LastFmApiProxy { + pub method: String, + pub data: HashMap, +} + +pub async fn create_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let mut user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let con = ( + LastFmConnection::connection(), + ExternalConnectionData::default(), + ); + + user.connections + .insert(ConnectionService::LastFm, con.clone()); + + if let Err(e) = data + .update_user_connections(user.id, user.connections) + .await + { + return Json(e.into()); + } + + Json(ApiReturn { + ok: true, + message: "Connection created".to_string(), + payload: Some(con.0.data), + }) +} + +pub async fn proxy_request( + jar: CookieJar, + Extension(data): Extension, + Json(mut req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if let None = user.connections.get(&ConnectionService::LastFm) { + // connection doesn't exist + return Json(Error::GeneralNotFound("connection".to_string()).into()); + }; + + req.data.insert("method".to_string(), req.method); + req.data.insert("format".to_string(), "json".to_string()); + req.data.insert( + "api_key".to_string(), + data.0.connections.last_fm_key.as_ref().unwrap().to_string(), + ); + req.data.insert( + "api_sig".to_string(), + LastFmConnection::signature( + req.data.clone(), + data.0 + .connections + .last_fm_secret + .as_ref() + .unwrap() + .to_string(), + ), + ); + + // build url string + let mut out: String = String::new(); + + for (i, v) in req.data.iter().enumerate() { + if i == 0 { + out.push_str(&format!("?{}={}", v.0, v.1)); + } else { + out.push_str(&format!("&{}={}", v.0, v.1)); + } + } + + // ... + let res = reqwest::get(format!("https://ws.audioscrobbler.com/2.0/{out}")) + .await + .unwrap() + .text() + .await + .unwrap(); + + Json(ApiReturn { + ok: true, + message: String::new(), + payload: res, + }) +} diff --git a/crates/app/src/routes/api/v1/auth/connections/mod.rs b/crates/app/src/routes/api/v1/auth/connections/mod.rs index 3e8a2b8..a3bd829 100644 --- a/crates/app/src/routes/api/v1/auth/connections/mod.rs +++ b/crates/app/src/routes/api/v1/auth/connections/mod.rs @@ -1,3 +1,4 @@ +pub mod last_fm; pub mod spotify; use std::collections::HashMap; diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index bd92bcb..e19d2be 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -254,6 +254,14 @@ pub fn routes() -> Router { "/auth/user/connections/spotify", post(auth::connections::spotify::create_request), ) + .route( + "/auth/user/connections/last_fm", + post(auth::connections::last_fm::create_request), + ) + .route( + "/auth/user/connections/last_fm/api_proxy", + post(auth::connections::last_fm::proxy_request), + ) } #[derive(Deserialize)] diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 2da9075..8687aac 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -18,6 +18,10 @@ tetratto-l10n = { path = "../l10n" } serde_json = "1.0.140" totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"] } reqwest = { version = "0.12.15", features = ["json"] } +bitflags = "2.9.0" +async-recursion = "1.1.1" +md-5 = "0.10.6" +base16ct = { version = "0.2.0", features = ["alloc"] } redis = { version = "0.30.0", optional = true } @@ -25,5 +29,3 @@ rusqlite = { version = "0.35.0", optional = true } tokio-postgres = { version = "0.7.13", optional = true } bb8-postgres = { version = "0.9.0", optional = true } -bitflags = "2.9.0" -async-recursion = "1.1.1" diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index a74d24f..f2be8b5 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -155,12 +155,20 @@ pub struct ConnectionsConfig { /// #[serde(default)] pub spotify_client_id: Option, + /// + #[serde(default)] + pub last_fm_key: Option, + /// + #[serde(default)] + pub last_fm_secret: Option, } impl Default for ConnectionsConfig { fn default() -> Self { Self { spotify_client_id: None, + last_fm_key: None, + last_fm_secret: None, } } } diff --git a/crates/core/src/database/connections/last_fm.rs b/crates/core/src/database/connections/last_fm.rs new file mode 100644 index 0000000..f0df344 --- /dev/null +++ b/crates/core/src/database/connections/last_fm.rs @@ -0,0 +1,42 @@ +use std::collections::HashMap; +use crate::{ + config::Config, + model::auth::{ConnectionType, ExternalConnectionInfo, UserConnections}, +}; +use md5::{Md5, Digest}; +use base16ct::upper::encode_string; + +/// A connection to last.fm. +pub struct LastFmConnection(pub UserConnections, pub Config); + +impl LastFmConnection { + /// Create a legacy MD5 signature, since last.fm is old. + pub fn signature(params: HashMap, secret: String) -> String { + let mut params_string = String::new(); + + let mut params = params.clone(); + params.remove("format"); + + let mut x = params.iter().collect::>(); + x.sort_by(|a, b| a.0.cmp(b.0)); + + for param in x { + params_string.push_str(&format!("{}{}", param.0, param.1)); + } + + let mut hasher = Md5::new(); + hasher.update(format!("{params_string}{secret}")); + + let hash = hasher.finalize(); + encode_string(&hash) + } + + /// Create a new [`ExternalConnectionInfo`] for the connection. + pub fn connection() -> ExternalConnectionInfo { + ExternalConnectionInfo { + con_type: ConnectionType::Token, + data: HashMap::new(), + show_on_profile: true, + } + } +} diff --git a/crates/core/src/database/connections/mod.rs b/crates/core/src/database/connections/mod.rs index 16b75bc..59fd382 100644 --- a/crates/core/src/database/connections/mod.rs +++ b/crates/core/src/database/connections/mod.rs @@ -1 +1,2 @@ +pub mod last_fm; pub mod spotify; diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 855a094..2272c8d 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -346,6 +346,8 @@ impl User { pub enum ConnectionService { /// A connection to a Spotify account. Spotify, + /// A connection to a last.fm account. + LastFm, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]