Implement auth.getSession and user.getRecentTracks

This commit is contained in:
Evie Viau-Chow-Stuart 2025-05-19 19:42:28 -07:00
parent d62005c371
commit a6b9f8f675
Signed by: evie
GPG key ID: 928652CDFCEC8099
16 changed files with 3882 additions and 9 deletions

2
.gitignore vendored
View file

@ -1 +1 @@
/target
**/target

8
.idea/.gitignore generated vendored Normal file
View file

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

13
.idea/lastfm-vore.iml generated Normal file
View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="EMPTY_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/example/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/example/target" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/modules.xml generated Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/lastfm-vore.iml" filepath="$PROJECT_DIR$/.idea/lastfm-vore.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

1589
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,17 @@
[package]
name = "lastfm-vore"
license = "Apache-2.0"
version = "0.1.0"
edition = "2024"
[dependencies]
http = "1.3.1"
reqwest = { version = "0.12.15", features = ["json"] }
md5 = "0.7.0"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
thiserror = "2.0.12"

1749
example/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

12
example/Cargo.toml Normal file
View file

@ -0,0 +1,12 @@
[package]
name = "example"
version = "0.1.0"
edition = "2024"
[dependencies]
lastfm-vore = { path = "../" }
tokio = { version = "1.45.0", features = ["full"] }
axum = "0.8.4"
serde = { version = "1.0.219", features = ["derive"] }

83
example/src/main.rs Normal file
View file

@ -0,0 +1,83 @@
use std::env;
use std::fmt::format;
use axum::routing::get;
use axum::{Extension, Router};
use axum::extract::Query;
use axum::response::IntoResponse;
use lastfm_vore::{LastfmApiError, LastfmClient};
use serde::Deserialize;
use lastfm_vore::auth::get_session::GetSessionResponse;
#[derive(Clone)]
struct Context {
lastfm: LastfmClient,
}
#[tokio::main]
async fn main() {
let key = env::var("API_KEY").expect("API_KEY does not exist");
let secret_key = env::var("SECRET_KEY").expect("SECRET_KEY does not exist");
let lastfm_client = LastfmClient::new(&key, &secret_key);
let app = Router::new()
.route("/callback", get(callback))
.layer(Extension(Context { lastfm: lastfm_client }));
let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await.unwrap();
println!("wait a couple seconds and then head over to https://www.last.fm/api/auth/?api_key={}", key);
axum::serve(listener, app).await.unwrap();
}
#[derive(Deserialize, Debug)]
struct CallbackQuery {
token: String,
}
async fn callback(
Extension(ctx): Extension<Context>,
Query(query): Query<CallbackQuery>,
) -> String {
let res = lastfm_vore::auth::get_session::GetSession::send(&ctx.lastfm, query.token).await;
match res {
Ok(res) => {
let res_two = lastfm_vore::user::get_recent_tracks::GetRecentTracks::send(&ctx.lastfm, res.session.name.clone(), None, None, None, None, true).await;
match res_two {
Ok(res_two) => {
let res_two = res_two.recenttracks;
let track = res_two.track.first().unwrap();
let artist = track.artist.clone().unwrap();
let mut format_string = format!("Hello {}! ", res.session.name);
if track.is_playing() {
format_string.push_str("You're currently playing: ");
} else {
format_string.push_str("You last listened to: ");
}
format_string.push_str(format!("{} by {} ", track.name, artist.get_artist_name()).as_str());
if let Some(album) = track.album.clone() {
format_string.push_str(format!("on {}", album.text).as_str());
}
format_string.push_str("!");
format_string
}
Err(e) => {
e.to_string()
}
}
}
Err(e) => {
e.to_string()
}
}
}

26
src/auth/get_session.rs Normal file
View file

@ -0,0 +1,26 @@
use serde::{Deserialize};
use crate::auth::Session;
use crate::{ApiError, LastfmApiError, LastfmClient};
#[derive(Deserialize, Clone, Debug)]
pub struct GetSessionResponse {
pub session: Session,
}
pub struct GetSession;
impl GetSession {
pub async fn send(lastfm_client: &LastfmClient, token: String) -> Result<GetSessionResponse, LastfmApiError> {
let api_url = lastfm_client.build_get_url("auth.getSession", &[("token", token.as_str())], true);
let resp = reqwest::get(api_url.as_str())
.await?
.text()
.await?;
match serde_json::from_str::<GetSessionResponse>(resp.as_str()) {
Ok(resp) => Ok(resp),
Err(_) => Err(serde_json::from_str::<ApiError>(resp.as_str())?.into_error())
}
}
}

12
src/auth/mod.rs Normal file
View file

@ -0,0 +1,12 @@
use crate::util::bool_from_u8;
use serde::Deserialize;
pub mod get_session;
#[derive(Deserialize, Clone, Debug)]
pub struct Session {
pub name: String,
pub key: String,
#[serde(deserialize_with = "bool_from_u8")]
pub subscriber: bool
}

View file

@ -1,14 +1,149 @@
pub fn add(left: u64, right: u64) -> u64 {
left + right
use http::Uri;
use serde::Deserialize;
use thiserror::Error;
pub mod auth;
pub mod user;
mod util;
const DEFAULT_LAST_FM_API_URI: &str = "https://ws.audioscrobbler.com/2.0/";
#[derive(Debug, Clone)]
pub struct LastfmClient {
api_url: String,
key: String,
secret: String,
}
impl LastfmClient {
pub fn new(key: &str, secret: &str) -> Self {
LastfmClient {
api_url: DEFAULT_LAST_FM_API_URI.to_string(),
key: key.to_string(),
secret: secret.to_string(),
}
}
pub fn new_with_custom_api(url: &str, key: &str, secret: &str) -> Self {
LastfmClient {
api_url: url.parse::<Uri>().expect("Invalid API URL").to_string(),
key: key.to_string(),
secret: secret.to_string(),
}
}
fn sign_call(&self, method: &str, params: &[(&str, &str)]) -> md5::Digest {
let mut string = String::new();
string.push_str("api_key");
string.push_str(self.key.as_str());
string.push_str("method");
string.push_str(method);
for i in params {
string.push_str(&i.0);
string.push_str(&i.1);
}
string.push_str(self.secret.as_str());
md5::compute(string)
}
fn build_get_url(&self, method: &str, params: &[(&str, &str)], sign: bool) -> String {
let mut api_url = format!("{}?method={}&api_key={}", self.api_url, method, self.key);
for i in params {
api_url.push_str(format!("&{}={}", i.0, i.1).as_str());
}
if sign {
api_url.push_str(format!("&api_sig={:x}", self.sign_call(method, params)).as_str());
}
api_url.push_str("&format=json");
api_url
}
}
#[derive(Debug, Error)]
#[repr(u16)]
pub enum LastfmApiError {
#[error("This service does not exist")]
InvalidService = 2,
#[error("No method with that name in this package")]
InvalidMethod,
#[error("You do not have permissions to access the service")]
AuthenticationFailed,
#[error("This services doesn't exist in that format")]
InvalidFormat,
#[error("Your request is missing a required parameter")]
InvalidParameters,
#[error("Invalid resource specified")]
InvalidResource,
#[error("Something else went wrong")]
OperationFailed,
#[error("Please re-authenticate")]
InvalidSessionKey,
#[error("You must be granted a valid key by last.fm")]
InvalidAPIKey,
#[error("This service is temporarily offline. Try again later.")]
ServiceOffline,
#[error("Invalid method signature supplied")]
InvalidMethodSignature = 13,
#[error("This token has not been authorized")]
UnauthorizedToken,
#[error("This token has expired")]
ExpiredToken,
#[error("Access for your account has been suspended, please contact Last.fm")]
SuspendedKey = 26,
#[error("Your IP has made too many requests in a short period")]
RateLimitExceeded = 29,
#[error("Unknown Error")]
Unknown,
#[error(transparent)]
Reqwest(#[from] reqwest::Error),
#[error(transparent)]
SerdeJson(#[from] serde_json::Error),
}
#[derive(Deserialize, Debug)]
struct ApiError {
error: u16,
message: String,
}
impl ApiError {
pub fn into_error(self) -> LastfmApiError {
self.error.into()
}
}
impl From<u16> for LastfmApiError {
fn from(error: u16) -> Self {
match error {
2 => LastfmApiError::InvalidService,
3 => LastfmApiError::InvalidMethod,
4 => LastfmApiError::AuthenticationFailed,
5 => LastfmApiError::InvalidFormat,
6 => LastfmApiError::InvalidParameters,
7 => LastfmApiError::InvalidResource,
8 => LastfmApiError::OperationFailed,
9 => LastfmApiError::InvalidSessionKey,
10 => LastfmApiError::InvalidAPIKey,
11 => LastfmApiError::ServiceOffline,
13 => LastfmApiError::InvalidMethodSignature,
14 => LastfmApiError::UnauthorizedToken,
15 => LastfmApiError::ExpiredToken,
26 => LastfmApiError::SuspendedKey,
29 => LastfmApiError::RateLimitExceeded,
_ => LastfmApiError::Unknown,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}

View file

@ -0,0 +1,76 @@
use crate::util::i32_from_string;
use serde::Deserialize;
use crate::{ApiError, LastfmApiError, LastfmClient};
use crate::user::Track;
#[derive(Deserialize, Clone, Debug)]
pub struct GetRecentTracksResponse {
pub recenttracks: RecentTracks,
}
#[derive(Deserialize, Clone, Debug)]
pub struct RecentTracks {
pub track: Vec<Track>,
#[serde(rename(deserialize = "@attr"))]
pub attributes: GetRecentTracksAttributes,
}
#[derive(Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct GetRecentTracksAttributes {
pub user: String,
#[serde(deserialize_with = "i32_from_string")]
pub total_pages: i32,
#[serde(deserialize_with = "i32_from_string")]
pub page: i32,
#[serde(deserialize_with = "i32_from_string")]
pub per_page: i32,
#[serde(deserialize_with = "i32_from_string")]
pub total: i32
}
pub struct GetRecentTracks;
impl GetRecentTracks {
pub async fn send(lastfm_client: &LastfmClient, username: String, limit: Option<i32>, page: Option<i32>, from: Option<i32>, to: Option<i32>, extended: bool) -> Result<GetRecentTracksResponse, LastfmApiError> {
let mut params: Vec<(&str, String)> = Vec::new();
params.push(("user", username));
if let Some(l) = limit {
params.push(("limit", l.to_string()));
}
if let Some(p) = page {
params.push(("page", p.to_string()));
}
if let Some(f) = from {
params.push(("from", f.to_string()));
}
if let Some(t) = to {
params.push(("to", t.to_string()));
}
if extended {
params.push(("extended", "1".to_string()));
}
let api_url = lastfm_client.build_get_url("user.getRecentTracks", params.iter().map(| (k, v) | (*k, v.as_str())).collect::<Vec<(&str, &str)>>().as_slice(), false);
let resp = reqwest::get(api_url.as_str())
.await?
.text()
.await?;
match serde_json::from_str::<GetRecentTracksResponse>(resp.as_str()) {
Ok(resp) => Ok(resp),
Err(e) => {
println!("{:#?}", e);
Err(serde_json::from_str::<ApiError>(resp.as_str())?.into_error())
}
}
}
}

82
src/user/mod.rs Normal file
View file

@ -0,0 +1,82 @@
use crate::util::bool_from_string_u8;
use crate::util::i64_from_string;
use crate::util::bool_from_string;
use serde::Deserialize;
pub mod get_recent_tracks;
#[derive(Deserialize, Clone, Debug)]
pub struct Track {
pub mbid: String,
pub name: String,
pub url: String,
#[serde(deserialize_with = "bool_from_string_u8")]
pub streamable: bool,
pub image: Vec<Image>,
pub artist: Option<Artist>,
pub album: Option<MbidText>,
pub date: Option<UnixTimestampText>,
#[serde(default)]
#[serde(deserialize_with = "bool_from_string_u8")]
pub loved: bool,
#[serde(rename(deserialize = "@attr"))]
pub attributes: Option<TrackAttributes>,
}
#[derive(Deserialize, Clone, Debug)]
pub struct Artist {
pub url: Option<String>,
pub name: Option<String>,
pub image: Option<Vec<Image>>,
pub mbid: String,
#[serde(rename(deserialize = "#text"))]
pub text: Option<String>,
}
#[derive(Deserialize, Clone, Debug)]
pub struct MbidText {
pub mbid: String,
#[serde(rename(deserialize = "#text"))]
pub text: String,
}
#[derive(Deserialize, Clone, Debug)]
pub struct UnixTimestampText {
#[serde(deserialize_with = "i64_from_string")]
pub uts: i64,
#[serde(rename(deserialize = "#text"))]
pub text: String,
}
#[derive(Deserialize, Clone, Debug)]
pub struct Image {
pub size: String,
#[serde(rename(deserialize = "#text"))]
pub text: String,
}
#[derive(Deserialize, Clone, Debug)]
pub struct TrackAttributes {
#[serde(deserialize_with = "bool_from_string")]
pub nowplaying: bool,
}
impl Track {
pub fn is_playing(&self) -> bool {
if let Some(attr) = self.clone().attributes {
attr.nowplaying
} else {
false
}
}
}
impl Artist {
pub fn get_artist_name(&self) -> String {
if self.name.is_some() {
self.name.clone().unwrap()
} else {
self.mbid.clone()
}
}
}

63
src/util.rs Normal file
View file

@ -0,0 +1,63 @@
use serde::de::{self, Deserialize, Deserializer, Unexpected};
pub(crate) fn bool_from_string<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
D: Deserializer<'de>,
{
match String::deserialize(deserializer)?.as_str() {
"false" => Ok(false),
"true" => Ok(true),
other => Err(de::Error::invalid_value(
Unexpected::Str(other),
&"zero or one",
)),
}
}
pub(crate) fn bool_from_string_u8<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
D: Deserializer<'de>,
{
match String::deserialize(deserializer)?.as_str() {
"0" => Ok(false),
"1" => Ok(true),
other => Err(de::Error::invalid_value(
Unexpected::Str(other),
&"zero or one",
)),
}
}
pub(crate) fn bool_from_u8<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
D: Deserializer<'de>,
{
match u8::deserialize(deserializer)? {
0 => Ok(false),
1 => Ok(true),
other => Err(de::Error::invalid_value(
Unexpected::Unsigned(other as u64),
&"zero or one",
)),
}
}
pub(crate) fn i32_from_string<'de, D>(deserializer: D) -> Result<i32, D::Error>
where
D: Deserializer<'de>,
{
match String::deserialize(deserializer)?.as_str().parse::<i32>() {
Ok(res) => Ok(res),
Err(err) => Err(de::Error::invalid_value(Unexpected::Str(&err.to_string()), &"i32")),
}
}
pub(crate) fn i64_from_string<'de, D>(deserializer: D) -> Result<i64, D::Error>
where
D: Deserializer<'de>,
{
match String::deserialize(deserializer)?.as_str().parse::<i64>() {
Ok(res) => Ok(res),
Err(err) => Err(de::Error::invalid_value(Unexpected::Str(&err.to_string()), &"i64")),
}
}