add: last.fm status integration

This commit is contained in:
trisua 2025-04-26 19:23:30 -04:00
parent 3e2bdceb99
commit 0765156697
17 changed files with 397 additions and 3 deletions

View file

@ -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);

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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>

View file

@ -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

View file

@ -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>

View 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

View file

@ -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"],
},
},
]);
});
})();

View 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,
})
}

View file

@ -1,3 +1,4 @@
pub mod last_fm;
pub mod spotify;
use std::collections::HashMap;

View file

@ -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)]