add: last.fm status integration
This commit is contained in:
parent
3e2bdceb99
commit
0765156697
17 changed files with 397 additions and 3 deletions
8
Cargo.lock
generated
8
Cargo.lock
generated
|
@ -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",
|
||||
|
|
|
@ -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<RwLock<HashMap<String, String>>> =
|
||||
|
@ -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);
|
||||
|
|
|
@ -37,4 +37,29 @@ config.connections.spotify_client_id %}
|
|||
`<b>${message}.</b> You can now close this tab.`;
|
||||
}, 150);
|
||||
</script>
|
||||
{% elif connection_type == "LastFm" and user and user.connections.LastFm and
|
||||
config.connections.last_fm_key %}
|
||||
<script>
|
||||
setTimeout(async () => {
|
||||
const token = new URLSearchParams(window.location.search).get("token");
|
||||
const api_key = "{{ config.connections.last_fm_key }}";
|
||||
|
||||
if (!token) {
|
||||
alert("Connection failed (did not get token from Last.fm)");
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await trigger("last_fm::get_session", [token]);
|
||||
const { message } = await trigger("connections::push_con_data", [
|
||||
"LastFm",
|
||||
{
|
||||
session_token: res.session.key,
|
||||
name: res.session.name,
|
||||
},
|
||||
]);
|
||||
|
||||
document.getElementById("status").innerHTML =
|
||||
`<b>${message}.</b> You can now close this tab.`;
|
||||
}, 1000);
|
||||
</script>
|
||||
{% endif %} {% endblock %}
|
||||
|
|
|
@ -847,4 +847,38 @@ state.data %}
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %} {%- endmacro %} {% macro last_fm_playing(state, size="60px") -%} {%
|
||||
if state and state.data %}
|
||||
<div class="card-nest">
|
||||
<div class="card flex items-center justify-between gap-2 small">
|
||||
<div class="flex items-center gap-2">
|
||||
<b>Listening on</b>
|
||||
{{ icon "last_fm" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card secondary flex gap-2">
|
||||
<img
|
||||
src="{{ state.external_urls.track_img }}"
|
||||
alt="Track cover"
|
||||
loading="lazy"
|
||||
class="avatar"
|
||||
style="--size: {{ size }}"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<h5 class="w-full">
|
||||
<a href="{{ state.external_urls.track }}" class="flush"
|
||||
>{{ state.data.track }}</a
|
||||
>
|
||||
</h5>
|
||||
|
||||
<span class="fade"
|
||||
><a href="{{ state.external_urls.artist }}" class="flush"
|
||||
>{{ state.data.artist }}</a
|
||||
></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %} {%- endmacro %}
|
||||
|
|
|
@ -109,6 +109,8 @@
|
|||
<div style="display: contents;">
|
||||
{% 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 %}
|
||||
</div>
|
||||
|
||||
|
|
|
@ -515,13 +515,23 @@
|
|||
{{ icon "spotify" }}
|
||||
<span>Spotify</span>
|
||||
</button>
|
||||
{% endif %} {% if config.connections.last_fm_key and not
|
||||
user.connections.LastFm %}
|
||||
<button
|
||||
class="quaternary"
|
||||
onclick="trigger('last_fm::create_connection', ['{{ config.connections.last_fm_key }}'])"
|
||||
>
|
||||
{{ icon "last_fm" }}
|
||||
<span>Last.fm</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% for key, value in user.connections %}
|
||||
<div class="card-nest">
|
||||
<div class="card small flex items-center gap-2">
|
||||
{% if key == "Spotify" %} {{ icon "spotify" }} {% endif %}
|
||||
{% if key == "Spotify" %} {{ icon "spotify" }} {% elif key ==
|
||||
"LastFm" %} {{ icon "last_fm" }} {% endif %}
|
||||
|
||||
<b>
|
||||
{% if value[0].data.name %} {{ value[0].data.name }} {% else
|
||||
|
|
|
@ -381,6 +381,37 @@ macros -%}
|
|||
}
|
||||
}, 150);
|
||||
</script>
|
||||
{% elif user and user.connections.LastFm and
|
||||
config.connections.last_fm_key and
|
||||
user.connections.LastFm[0].data.session_token %}
|
||||
<script>
|
||||
setTimeout(async () => {
|
||||
if (window.last_fm_init) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.last_fm_init = true;
|
||||
const user = "{{ user.connections.LastFm[0].data.name }}";
|
||||
const session_token =
|
||||
"{{ user.connections.LastFm[0].data.session_token }}";
|
||||
|
||||
if (session_token) {
|
||||
// we already have a token
|
||||
const pull_playing = async () => {
|
||||
const playing = await trigger("last_fm::get_playing", [
|
||||
user,
|
||||
session_token,
|
||||
]);
|
||||
await trigger("last_fm::publish_playing", [playing]);
|
||||
};
|
||||
|
||||
await pull_playing();
|
||||
setInterval(pull_playing, 30_000);
|
||||
} else {
|
||||
window.last_fm_needs_token = true;
|
||||
}
|
||||
}, 150);
|
||||
</script>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
|
|
1
crates/app/src/public/images/vendor/last-fm.svg
vendored
Normal file
1
crates/app/src/public/images/vendor/last-fm.svg
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 512 284.498"><path fill="#D0232B" d="M225.779 253.403l-18.823-51.007s-30.491 34.026-76.188 34.026c-40.526 0-69.224-35.199-69.224-91.497 0-72.096 36.378-97.891 72.097-97.891 51.584 0 67.997 33.411 82.111 76.205l18.823 58.593c18.813 56.894 54.021 102.609 155.399 102.609 72.701 0 122.026-22.309 122.026-80.914 0-47.49-27.007-72.097-77.418-83.898l-37.494-8.191c-25.807-5.887-33.411-16.412-33.411-34.028 0-19.908 15.808-31.713 41.615-31.713 28.186 0 43.39 10.592 45.687 35.813l58.602-7.003C504.877 21.695 468.496 0 408.693 0c-52.811 0-104.404 19.908-104.404 83.907 0 39.904 19.391 65.085 68.005 76.802l39.904 9.4c29.877 7.013 39.894 19.391 39.894 36.391 0 21.695-21.099 30.494-60.993 30.494-59.21 0-83.908-31.108-97.904-73.893l-19.391-58.593C249.297 28.211 209.998.091 132.014.091 45.791.091 0 54.593 0 147.296c0 89.096 45.697 137.202 127.914 137.202 66.201 0 97.892-31.107 97.892-31.107l-.027.009v.003z"/></svg>
|
After Width: | Height: | Size: 1.1 KiB |
|
@ -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) => {
|
||||
// <https://lastfm-docs.github.io/api-docs/user/getRecentTracks/>
|
||||
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"],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
})();
|
||||
|
|
111
crates/app/src/routes/api/v1/auth/connections/last_fm.rs
Normal file
111
crates/app/src/routes/api/v1/auth/connections/last_fm.rs
Normal file
|
@ -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<String, String>,
|
||||
}
|
||||
|
||||
pub async fn create_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
) -> 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<State>,
|
||||
Json(mut req): Json<LastFmApiProxy>,
|
||||
) -> 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,
|
||||
})
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
pub mod last_fm;
|
||||
pub mod spotify;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
|
|
@ -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)]
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -155,12 +155,20 @@ pub struct ConnectionsConfig {
|
|||
/// <https://developer.spotify.com/documentation/web-api>
|
||||
#[serde(default)]
|
||||
pub spotify_client_id: Option<String>,
|
||||
/// <https://www.last.fm/api/authspec>
|
||||
#[serde(default)]
|
||||
pub last_fm_key: Option<String>,
|
||||
/// <https://www.last.fm/api/authspec>
|
||||
#[serde(default)]
|
||||
pub last_fm_secret: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ConnectionsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
spotify_client_id: None,
|
||||
last_fm_key: None,
|
||||
last_fm_secret: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
42
crates/core/src/database/connections/last_fm.rs
Normal file
42
crates/core/src/database/connections/last_fm.rs
Normal file
|
@ -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<String, String>, secret: String) -> String {
|
||||
let mut params_string = String::new();
|
||||
|
||||
let mut params = params.clone();
|
||||
params.remove("format");
|
||||
|
||||
let mut x = params.iter().collect::<Vec<(&String, &String)>>();
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1 +1,2 @@
|
|||
pub mod last_fm;
|
||||
pub mod spotify;
|
||||
|
|
|
@ -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)]
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue